Skip to main content

spec_driven_docs/
stage.rs

1//! The stage: a persistent workbench holding one rendered candidate.
2//!
3//! A stage is evidence an agent reads. It holds every destination the
4//! candidate would land, the reference material of the exact installed
5//! version, and a receipt describing both. It is written outside the target
6//! and never into it, and no production verb reads a byte of it: a landing
7//! renders the candidate again from this binary's own sources.
8//!
9//! The directory is renamed into place once it is complete, so a stage a
10//! reader can see is a stage that finished. Only `sdd stage clean` removes
11//! one, and only after the receipt inside it says this tool wrote it.
12
13use camino::{Utf8Path, Utf8PathBuf};
14use serde::Serialize;
15
16use crate::domain::manifest::MANIFEST_PATH;
17use crate::domain::profile::ProfileId;
18use crate::error::AppError;
19
20/// The machine schema `stage.json` declares.
21pub const STAGE_SCHEMA: &str = "sdd.stage/1";
22
23/// The base directory an automation points every stage at.
24pub const STAGE_ROOT_VAR: &str = "SDD_STAGE_ROOT";
25
26/// The receipt every stage carries, and the one file `clean` trusts.
27pub const RECEIPT_FILE: &str = "stage.json";
28
29/// Where the candidate's own destinations are rendered.
30pub const ARTIFACTS_DIR: &str = "artifacts";
31
32/// Where the installed version's reference material is copied.
33pub const REFERENCE_DIR: &str = "reference";
34
35/// The payload roots a stage carries as reference material.
36///
37/// Every root the binary carries that an adopting project reads while it
38/// migrates. The repository's own decision records and tests are not here:
39/// they are this project's history and its proof, and neither is knowledge
40/// an adopting project reads.
41const REFERENCE_ROOTS: [&str; 8] = [
42    "method",
43    "templates",
44    "_docs/specs",
45    "instance",
46    "comparison-docs",
47    "reference/prior-art",
48    "reference/tracker-markup",
49    ".markdownlint",
50];
51
52/// What a stage was asked to render.
53///
54/// Every choice a landing takes is here too. A stage rendered under
55/// different choices than the landing that follows it would be evidence
56/// about a candidate nobody is going to write.
57#[derive(Debug, Clone)]
58pub struct Request {
59    /// The repository to render a candidate for.
60    pub target: Utf8PathBuf,
61    /// The profile to project.
62    pub profile: ProfileId,
63    /// The stage directory the operator named.
64    pub output: Option<Utf8PathBuf>,
65    /// The documentation scratch the candidate would record.
66    pub docs_scratch: Option<Option<Utf8PathBuf>>,
67    /// Paths the candidate would record under `reserved:`.
68    pub reserve: Vec<String>,
69    /// The writing-style selection the candidate would record.
70    pub writing_style: Option<crate::domain::instance_config::WritingStyle>,
71}
72
73/// Which rule chose the stage root, for the report that names it.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
75#[serde(rename_all = "kebab-case")]
76pub enum RootSource {
77    /// The `--output` flag.
78    Flag,
79    /// The `SDD_STAGE_ROOT` base an automation declared.
80    Environment,
81    /// This tool's own state root.
82    Default,
83}
84
85/// One artifact the stage carries, and what a landing would do with it.
86#[derive(Debug, Clone, Serialize)]
87pub struct Artifact {
88    /// The destination, relative to the target.
89    pub path: String,
90    /// Who owns the bytes after a landing.
91    pub ownership: String,
92    /// Whether the candidate carries the file or one region of it.
93    pub placement: String,
94}
95
96/// The receipt a stage carries, and the report the command prints.
97#[derive(Debug, Clone, Serialize)]
98pub struct Receipt {
99    /// The machine schema of this record.
100    pub schema: &'static str,
101    /// The version of the binary that rendered the candidate.
102    pub version: String,
103    /// The target this candidate was rendered for.
104    pub target: Utf8PathBuf,
105    /// The profile projected.
106    pub profile: ProfileId,
107    /// Where this stage sits.
108    pub root: Utf8PathBuf,
109    /// Which rule chose that root.
110    pub root_source: RootSource,
111    /// The version the target's own record claims, where one is readable.
112    pub recorded_version: Option<String>,
113    /// The documentation root the candidate records.
114    pub docs_root: String,
115    /// The documentation scratch the candidate records.
116    pub docs_scratch: Option<Utf8PathBuf>,
117    /// The paths the candidate's own declaration reserves.
118    pub reserve: Vec<String>,
119    /// The writing source the candidate's own declaration selects.
120    pub writing_style: String,
121    /// Every destination the candidate would land.
122    pub artifacts: Vec<Artifact>,
123    /// Every reference root copied, relative to the stage.
124    pub reference: Vec<String>,
125    /// What the projection chose to leave alone, and why.
126    pub notes: Vec<String>,
127}
128
129/// Where a stage goes, and which rule decided.
130///
131/// The flag wins, then the automation base, then this tool's state root.
132/// The base is a directory of stages rather than one stage, so an
133/// automation that stages many targets names one variable.
134///
135/// # Errors
136///
137/// [`AppError::Usage`] when the resolved path is relative.
138pub fn resolve_root(
139    target: &Utf8Path,
140    output: Option<&Utf8Path>,
141    env: Option<&str>,
142    state_root: &Utf8Path,
143) -> Result<(Utf8PathBuf, RootSource), AppError> {
144    let (root, source) = match (output, env) {
145        (Some(named), _) => (named.to_owned(), RootSource::Flag),
146        (None, Some(base)) if !base.is_empty() => (
147            Utf8Path::new(base).join(stage_name(target)),
148            RootSource::Environment,
149        ),
150        _ => (
151            state_root.join("stages").join(stage_name(target)),
152            RootSource::Default,
153        ),
154    };
155    if !root.is_absolute() {
156        return Err(AppError::Usage(format!(
157            "the stage root must be absolute: {root}"
158        )));
159    }
160    // Whatever chose the root, a relative step in it resolves only once
161    // the directories exist, which is after the containment check has
162    // already run. The path the operator means is the one to give.
163    if root
164        .components()
165        .any(|part| part.as_str() == ".." || part.as_str() == ".")
166    {
167        return Err(AppError::Usage(format!(
168            "the stage root names a relative step: {root}; give the path it resolves to"
169        )));
170    }
171    Ok((root, source))
172}
173
174/// The nearest existing ancestor of a path, resolved through every link.
175///
176/// A path that does not exist yet still has an ancestor that does, and
177/// that is what says where it will really be created.
178fn resolved_ancestor(path: &Utf8Path) -> Utf8PathBuf {
179    let mut walked = path.to_owned();
180    loop {
181        if let Ok(canonical) = std::fs::canonicalize(walked.as_std_path())
182            && let Ok(canonical) = Utf8PathBuf::from_path_buf(canonical)
183        {
184            let rest = path
185                .strip_prefix(&walked)
186                .unwrap_or_else(|_| Utf8Path::new(""));
187            return canonical.join(rest);
188        }
189        let Some(parent) = walked.parent() else {
190            return path.to_owned();
191        };
192        walked = parent.to_owned();
193    }
194}
195
196/// The writing source a declaration selects, as one word a reader keeps.
197fn writing_style_of(declaration: &crate::domain::instance_config::InstanceConfig) -> String {
198    use crate::domain::instance_config::WritingSource;
199
200    match declaration.writing_style.source {
201        WritingSource::Builtin => "builtin".to_string(),
202        WritingSource::None => "none".to_string(),
203        WritingSource::Project => declaration
204            .writing_style
205            .path
206            .as_ref()
207            .map_or_else(|| "project".to_string(), |path| format!("project:{path}")),
208    }
209}
210
211/// A scratch sibling this run alone owns.
212///
213/// The name carries the process and a timestamp, and the directory is
214/// created exclusively, so the run never removes a path somebody else
215/// left. A collision refuses rather than clearing what is there.
216fn scratch_beside(root: &Utf8Path) -> Result<Utf8PathBuf, AppError> {
217    let stamp = jiff::Timestamp::now().as_nanosecond();
218    let partial = Utf8PathBuf::from(format!("{root}.partial-{}-{stamp}", std::process::id()));
219    if let Some(parent) = partial.parent() {
220        std::fs::create_dir_all(parent)?;
221    }
222    std::fs::create_dir(&partial).map_err(|source| {
223        AppError::Refused(format!(
224            "{partial} could not be created for this run: {source}"
225        ))
226    })?;
227    Ok(partial)
228}
229
230/// One target's stage directory name: its own name and a digest of its path.
231///
232/// Two checkouts of one repository stage side by side, and neither name is
233/// guessed from the other.
234fn stage_name(target: &Utf8Path) -> String {
235    let digest = crate::domain::ownership::Sha256::of(target.as_str().as_bytes()).to_string();
236    let leaf = target.file_name().unwrap_or("target");
237    format!("{leaf}-{}", &digest[..12])
238}
239
240/// Render one candidate into a stage, and report what it holds.
241///
242/// # Errors
243///
244/// [`AppError::Usage`] for a target or stage path the arguments cannot
245/// mean, [`AppError::Refused`] where the stage directory already holds
246/// something, and I/O errors writing the stage.
247pub fn create(request: &Request, state_root: &Utf8Path) -> Result<Receipt, AppError> {
248    let target = crate::services::installer::resolved_target(&request.target)?;
249    let env = std::env::var(STAGE_ROOT_VAR).ok();
250    let (root, root_source) = resolve_root(
251        &target,
252        request.output.as_deref(),
253        env.as_deref(),
254        state_root,
255    )?;
256
257    // A stage inside the target would be the one thing this command
258    // promises not to do: put the candidate and its reference corpus into
259    // the repository it is supposed to leave alone. The comparison is
260    // between resolved paths, because a link in the output's ancestry
261    // would otherwise carry it back inside.
262    let resolved = resolved_ancestor(&root);
263    if resolved == target || resolved.starts_with(&target) {
264        return Err(AppError::Refused(format!(
265            "{root} is inside {target}, and a stage is written outside the target it describes"
266        )));
267    }
268    if root.exists() && root.read_dir_utf8().is_ok_and(|mut it| it.next().is_some()) {
269        return Err(AppError::Refused(format!(
270            "{root} already holds a stage; read it, or remove it with 'sdd stage clean {root}'"
271        )));
272    }
273
274    let options = crate::services::installer::InitOptions {
275        target: target.clone(),
276        profile: request.profile,
277        apply: false,
278        dry_run: true,
279        docs_scratch: request.docs_scratch.clone(),
280        reserve: request.reserve.clone(),
281        writing_style: request.writing_style.clone(),
282    };
283    let candidate = crate::services::installer::candidate_for(&target, &options)?;
284
285    // Render beside the destination and rename the finished directory into
286    // place, so a reader never meets a stage that is still being written.
287    // The scratch name is this run's own: a predictable one would make the
288    // command remove a sibling nothing proves it wrote.
289    let partial = scratch_beside(&root)?;
290    let render = render(&partial, &target, &root, root_source, &candidate, request);
291    match render {
292        Ok(receipt) => finish(&partial, &root, receipt),
293        Err(error) => {
294            // The directory is this run's own, so removing it takes back
295            // only what this run made. A failure that left it behind would
296            // accumulate debris no command removes.
297            let _ = std::fs::remove_dir_all(&partial);
298            Err(error)
299        }
300    }
301}
302
303/// Rename the finished directory into place.
304fn finish(partial: &Utf8Path, root: &Utf8Path, receipt: Receipt) -> Result<Receipt, AppError> {
305    if let Some(parent) = root.parent() {
306        std::fs::create_dir_all(parent)?;
307    }
308    // An empty directory at the destination is the one thing a rename
309    // cannot land on. Anything else there already refused above.
310    let _ = std::fs::remove_dir(root);
311    if let Err(source) = std::fs::rename(partial, root) {
312        let _ = std::fs::remove_dir_all(partial);
313        return Err(AppError::Io(source));
314    }
315    Ok(receipt)
316}
317
318/// Write one candidate and its reference material under `partial`.
319fn render(
320    partial: &Utf8Path,
321    target: &Utf8Path,
322    root: &Utf8Path,
323    root_source: RootSource,
324    candidate: &crate::candidate::Candidate,
325    request: &Request,
326) -> Result<Receipt, AppError> {
327    let mut artifacts = Vec::new();
328    for destination in &candidate.destinations {
329        let path = partial.join(ARTIFACTS_DIR).join(&destination.path);
330        crate::adapters::fs::write_file(&path, &destination.bytes)?;
331        artifacts.push(Artifact {
332            path: destination.path.to_string(),
333            ownership: match destination.ownership {
334                crate::candidate::Ownership::Managed => "managed",
335                crate::candidate::Ownership::Adopted => "adopted",
336                crate::candidate::Ownership::Integration => "integration",
337            }
338            .to_string(),
339            placement: match destination.placement {
340                crate::candidate::Placement::WholeFile => "whole-file",
341                crate::candidate::Placement::MarkedRegion => "marked-region",
342            }
343            .to_string(),
344        });
345    }
346    crate::adapters::fs::write_file(
347        &partial.join(ARTIFACTS_DIR).join(MANIFEST_PATH),
348        candidate.manifest.to_json().as_bytes(),
349    )?;
350    artifacts.push(Artifact {
351        path: MANIFEST_PATH.to_string(),
352        ownership: "record".to_string(),
353        placement: "whole-file".to_string(),
354    });
355
356    let mut reference = Vec::new();
357
358    // The skills are copied as the packages an agent resolves, not as the
359    // authored tree: a skill names its gates relative to its own root, and
360    // a copy that split them would carry two broken references.
361    for name in crate::embedded::skill_names() {
362        let Some(package) = crate::embedded::skill_package(name) else {
363            continue;
364        };
365        for (relative, bytes) in package {
366            crate::adapters::fs::write_file(
367                &partial
368                    .join(REFERENCE_DIR)
369                    .join("skills")
370                    .join(name)
371                    .join(&relative),
372                bytes,
373            )?;
374        }
375    }
376    reference.push(format!("{REFERENCE_DIR}/skills"));
377
378    // The release notes of the exact installed version, which is where a
379    // migration reads what this release asked of an instance.
380    crate::adapters::fs::write_file(
381        &partial.join(REFERENCE_DIR).join("CHANGELOG.md"),
382        crate::embedded::CHANGELOG.as_bytes(),
383    )?;
384    reference.push(format!("{REFERENCE_DIR}/CHANGELOG.md"));
385
386    for root_name in REFERENCE_ROOTS {
387        let mut carried = false;
388        for (path, bytes) in crate::embedded::assets_under(root_name) {
389            crate::adapters::fs::write_file(&partial.join(REFERENCE_DIR).join(&path), bytes)?;
390            carried = true;
391        }
392        if carried {
393            reference.push(format!("{REFERENCE_DIR}/{root_name}"));
394        }
395    }
396    if let Some(bytes) = crate::embedded::asset("instance/docs-catalog.toml") {
397        crate::adapters::fs::write_file(
398            &partial
399                .join(REFERENCE_DIR)
400                .join("instance/docs-catalog.toml"),
401            bytes,
402        )?;
403        reference.push(format!("{REFERENCE_DIR}/instance/docs-catalog.toml"));
404    }
405
406    let receipt = Receipt {
407        schema: STAGE_SCHEMA,
408        version: crate::domain::version::CanonVersion::current().to_string(),
409        target: target.to_owned(),
410        profile: request.profile,
411        root: root.to_owned(),
412        root_source,
413        recorded_version: crate::services::installer::recorded_field(target, "canon_version")
414            .and_then(|value| value.as_str().map(String::from)),
415        docs_root: candidate.manifest.docs_root.to_string(),
416        docs_scratch: candidate.manifest.docs_scratch.clone(),
417        reserve: candidate.declaration.reserved.clone(),
418        writing_style: writing_style_of(&candidate.declaration),
419        artifacts,
420        reference,
421        notes: candidate.notes.clone(),
422    };
423    let json = serde_json::to_string_pretty(&receipt)
424        .map_err(|error| anyhow::anyhow!("the stage receipt does not serialize: {error}"))?;
425    crate::adapters::fs::write_file(&partial.join(RECEIPT_FILE), json.as_bytes())?;
426
427    Ok(receipt)
428}
429
430/// Remove one stage this tool wrote, and refuse anything else.
431///
432/// # Errors
433///
434/// [`AppError::Usage`] for a relative path, and [`AppError::Refused`] for a
435/// path that is not a stage, a stage whose receipt names another root, a
436/// link, or a root this tool will not recurse into.
437pub fn clean(path: &Utf8Path) -> Result<Vec<String>, AppError> {
438    if !path.is_absolute() {
439        return Err(AppError::Usage(format!(
440            "the stage path must be absolute: {path}"
441        )));
442    }
443    // The final component is inspected without following it, so a link
444    // standing where a stage stood cannot redirect the removal.
445    let held = std::fs::symlink_metadata(path)
446        .map_err(|source| AppError::Refused(format!("{path}: {source}")))?;
447    if held.file_type().is_symlink() {
448        return Err(AppError::Refused(format!(
449            "{path} is a symlink; a stage is a directory this tool wrote"
450        )));
451    }
452    if !held.is_dir() {
453        return Err(AppError::Refused(format!("{path} is not a directory")));
454    }
455    if path.parent().is_none() || path.as_str().matches('/').count() < 2 {
456        return Err(AppError::Refused(format!(
457            "{path} is too close to the filesystem root to remove"
458        )));
459    }
460    if path.join(MANIFEST_PATH).exists() || path.join(".git").exists() {
461        return Err(AppError::Refused(format!(
462            "{path} looks like a project rather than a stage"
463        )));
464    }
465    // A receipt is ordinary user-writable JSON, so it proves nothing on
466    // its own. These refusals are what stand between a forged one and a
467    // recursive removal of somewhere that matters.
468    if let Some(home) = crate::domain::paths::UserEnv::from_process().home
469        && path == home
470    {
471        return Err(AppError::Refused(format!(
472            "{path} is the home directory, which is never a stage"
473        )));
474    }
475    if let Ok(cwd) = std::env::current_dir()
476        && let Ok(cwd) = Utf8PathBuf::from_path_buf(cwd)
477        && cwd.starts_with(path)
478    {
479        return Err(AppError::Refused(format!(
480            "{path} holds the working directory, so it is not a stage this tool wrote"
481        )));
482    }
483    // A stage this tool wrote holds the tree it renders. A directory that
484    // carries a receipt and none of that structure is something else
485    // wearing the name.
486    if !path.join(ARTIFACTS_DIR).is_dir() {
487        return Err(AppError::Refused(format!(
488            "{path} carries no {ARTIFACTS_DIR}/ directory, so it is not a stage this tool wrote"
489        )));
490    }
491
492    let receipt_path = path.join(RECEIPT_FILE);
493    let text = std::fs::read_to_string(&receipt_path).map_err(|source| {
494        AppError::Refused(format!(
495            "{receipt_path} is not readable, so this is not a stage this tool wrote: {source}"
496        ))
497    })?;
498    let receipt: serde_json::Value = serde_json::from_str(&text)
499        .map_err(|source| AppError::Refused(format!("{receipt_path} does not parse: {source}")))?;
500    if receipt.get("schema").and_then(serde_json::Value::as_str) != Some(STAGE_SCHEMA) {
501        return Err(AppError::Refused(format!(
502            "{receipt_path} does not declare {STAGE_SCHEMA}, so this is not a stage"
503        )));
504    }
505    let declared = receipt
506        .get("root")
507        .and_then(serde_json::Value::as_str)
508        .ok_or_else(|| AppError::Refused(format!("{receipt_path} declares no root")))?;
509    if declared != path.as_str() {
510        return Err(AppError::Refused(format!(
511            "{receipt_path} declares the root {declared}, and this path is {path}"
512        )));
513    }
514
515    // Move the validated directory aside under a name this run owns, then
516    // remove that. A directory swapped in after the checks is left where
517    // it is, because what this removes is the thing it just moved.
518    let aside = scratch_beside(path)?;
519    let _ = std::fs::remove_dir(&aside);
520    std::fs::rename(path, &aside)?;
521    if let Err(source) = std::fs::remove_dir_all(&aside) {
522        // Put it back under the name the operator knows, so a second try
523        // can find it. Where even that fails, name both paths: a stage
524        // nobody can address again is worse than the failure itself.
525        if std::fs::rename(&aside, path).is_err() {
526            return Err(AppError::Refused(format!(
527                "{path} could not be removed and is now at {aside}: {source}"
528            )));
529        }
530        return Err(AppError::Io(source));
531    }
532    Ok(vec![format!("removed {path}")])
533}
534
535#[cfg(test)]
536mod tests {
537    #![allow(
538        clippy::unwrap_used,
539        reason = "a test panics as its failure signal, not as control flow"
540    )]
541
542    use super::*;
543
544    fn root(dir: &tempfile::TempDir) -> Utf8PathBuf {
545        Utf8PathBuf::from(dir.path().to_str().unwrap())
546    }
547
548    #[test]
549    fn the_flag_wins_then_the_base_then_the_state_root() {
550        let target = Utf8Path::new("/work/project");
551        let state = Utf8Path::new("/state/spec-driven-docs");
552
553        let (path, source) = resolve_root(
554            target,
555            Some(Utf8Path::new("/tmp/here")),
556            Some("/base"),
557            state,
558        )
559        .unwrap();
560        assert_eq!(path, "/tmp/here");
561        assert_eq!(source, RootSource::Flag);
562
563        let (path, source) = resolve_root(target, None, Some("/base"), state).unwrap();
564        assert!(path.as_str().starts_with("/base/project-"), "{path}");
565        assert_eq!(source, RootSource::Environment);
566
567        let (path, source) = resolve_root(target, None, None, state).unwrap();
568        assert!(
569            path.as_str().starts_with(state.join("stages").as_str()),
570            "{path}"
571        );
572        assert_eq!(source, RootSource::Default);
573    }
574
575    #[test]
576    fn a_relative_stage_root_is_a_usage_error() {
577        let error = resolve_root(
578            Utf8Path::new("/work/project"),
579            Some(Utf8Path::new("stage")),
580            None,
581            Utf8Path::new("/state"),
582        )
583        .unwrap_err();
584        assert_eq!(error.kind(), "Usage");
585    }
586
587    #[test]
588    fn a_relative_step_in_the_stage_root_is_a_usage_error() {
589        // A path with a nonexistent prefix and a `..` resolves somewhere
590        // the containment check cannot see until the directory exists.
591        for (output, env) in [
592            (Some(Utf8Path::new("/work/project/absent/../inside")), None),
593            (None, Some("/tmp/absent/../../work")),
594        ] {
595            let error = resolve_root(
596                Utf8Path::new("/work/project"),
597                output,
598                env,
599                Utf8Path::new("/state"),
600            )
601            .unwrap_err();
602            assert_eq!(error.kind(), "Usage");
603            assert!(error.to_string().contains("relative step"), "{error}");
604        }
605    }
606
607    #[test]
608    fn two_targets_stage_under_different_names() {
609        assert_ne!(
610            stage_name(Utf8Path::new("/one/project")),
611            stage_name(Utf8Path::new("/two/project"))
612        );
613    }
614
615    #[test]
616    fn clean_refuses_a_directory_that_is_not_a_stage() {
617        let dir = tempfile::tempdir().unwrap();
618        let path = root(&dir).join("not-a-stage");
619        std::fs::create_dir_all(&path).unwrap();
620        let error = clean(&path).unwrap_err();
621        assert_eq!(error.kind(), "Refused");
622        assert!(path.exists(), "the directory was removed anyway");
623    }
624
625    #[test]
626    fn clean_refuses_a_receipt_that_names_another_root() {
627        let dir = tempfile::tempdir().unwrap();
628        let path = root(&dir).join("stage");
629        std::fs::create_dir_all(path.join(ARTIFACTS_DIR)).unwrap();
630        crate::adapters::fs::write_file(
631            &path.join(RECEIPT_FILE),
632            format!(r#"{{"schema":"{STAGE_SCHEMA}","root":"/elsewhere"}}"#).as_bytes(),
633        )
634        .unwrap();
635        let error = clean(&path).unwrap_err();
636        assert!(error.to_string().contains("/elsewhere"), "{error}");
637        assert!(path.exists());
638    }
639
640    #[test]
641    fn clean_refuses_a_link_standing_where_a_stage_stood() {
642        let dir = tempfile::tempdir().unwrap();
643        let real = root(&dir).join("stage");
644        std::fs::create_dir_all(real.join(ARTIFACTS_DIR)).unwrap();
645        crate::adapters::fs::write_file(
646            &real.join(RECEIPT_FILE),
647            format!(r#"{{"schema":"{STAGE_SCHEMA}","root":"{real}"}}"#).as_bytes(),
648        )
649        .unwrap();
650        let link = root(&dir).join("link");
651        std::os::unix::fs::symlink(real.as_std_path(), link.as_std_path()).unwrap();
652
653        let error = clean(&link).unwrap_err();
654        assert!(error.to_string().contains("symlink"), "{error}");
655        assert!(real.exists(), "the link's target was removed");
656    }
657
658    #[test]
659    fn clean_refuses_a_project_root() {
660        let dir = tempfile::tempdir().unwrap();
661        let path = root(&dir).join("project");
662        crate::adapters::fs::write_file(&path.join(MANIFEST_PATH), b"{}").unwrap();
663        crate::adapters::fs::write_file(
664            &path.join(RECEIPT_FILE),
665            format!(r#"{{"schema":"{STAGE_SCHEMA}","root":"{path}"}}"#).as_bytes(),
666        )
667        .unwrap();
668        let error = clean(&path).unwrap_err();
669        assert!(error.to_string().contains("project"), "{error}");
670        assert!(path.exists());
671    }
672
673    #[test]
674    fn clean_refuses_a_forged_receipt_over_a_directory_that_is_not_a_stage() {
675        let dir = tempfile::tempdir().unwrap();
676        // Somebody's directory of work, with a receipt dropped in it that
677        // names the directory as its own root.
678        let path = root(&dir).join("someones-files");
679        crate::adapters::fs::write_file(&path.join("notes.md"), b"mine").unwrap();
680        crate::adapters::fs::write_file(
681            &path.join(RECEIPT_FILE),
682            format!(r#"{{"schema":"{STAGE_SCHEMA}","root":"{path}"}}"#).as_bytes(),
683        )
684        .unwrap();
685
686        let error = clean(&path).unwrap_err();
687        assert!(error.to_string().contains(ARTIFACTS_DIR), "{error}");
688        assert!(path.join("notes.md").exists(), "the directory was removed");
689    }
690
691    #[test]
692    fn clean_refuses_a_directory_holding_the_working_directory() {
693        let dir = tempfile::tempdir().unwrap();
694        let path = Utf8PathBuf::from_path_buf(std::env::current_dir().unwrap())
695            .unwrap()
696            .parent()
697            .unwrap()
698            .to_owned();
699        let _ = dir;
700        let error = clean(&path).unwrap_err();
701        assert_eq!(error.kind(), "Refused");
702        assert!(path.exists());
703    }
704
705    #[test]
706    fn clean_removes_one_valid_stage() {
707        let dir = tempfile::tempdir().unwrap();
708        let path = root(&dir).join("stage");
709        crate::adapters::fs::write_file(&path.join(ARTIFACTS_DIR).join("AGENTS.md"), b"x").unwrap();
710        crate::adapters::fs::write_file(
711            &path.join(RECEIPT_FILE),
712            format!(r#"{{"schema":"{STAGE_SCHEMA}","root":"{path}"}}"#).as_bytes(),
713        )
714        .unwrap();
715        assert_eq!(clean(&path).unwrap().len(), 1);
716        assert!(!path.exists());
717    }
718}