Skip to main content

codegen/app/
output_tree.rs

1use std::{collections::BTreeSet, process::ExitCode};
2
3use anyhow::{Context, bail};
4use wowlab_common::output;
5use wowlab_fs::{
6    artifact::{GeneratedTextFile, Status},
7    directory::{self, EntryKind},
8    path::{Path, PathBuf},
9};
10
11use super::ownership::{
12    OWNERSHIP_MANIFEST, adopt_legacy_generated_tree, ownership_manifest_source,
13    prune_empty_owned_parents, read_ownership_manifest, reject_unowned_files,
14    validate_owned_file_kinds, validate_owned_path, write_ownership_manifest,
15};
16
17pub(super) fn write_generated_files(
18    files: &[(String, String)],
19    output_dir: &Path,
20    quiet: bool,
21) -> anyhow::Result<()> {
22    let expected_paths = expected_path_set(files)?;
23    let actual_paths = actual_file_set(output_dir)?;
24
25    let previous_paths = match read_ownership_manifest(output_dir)? {
26        Some(paths) => {
27            reject_unowned_files(&actual_paths, &paths)?;
28            validate_owned_file_kinds(output_dir, &paths)?;
29
30            paths
31        }
32        None => adopt_legacy_generated_tree(output_dir, &actual_paths)?,
33    };
34
35    // Cover both old and new artifacts before any output-tree mutation.
36    let transitional_paths = previous_paths
37        .union(&expected_paths)
38        .cloned()
39        .collect::<BTreeSet<_>>();
40
41    write_ownership_manifest(output_dir, &transitional_paths)?;
42
43    for obsolete in previous_paths.difference(&expected_paths) {
44        let path = output_dir.join(obsolete);
45
46        if !directory::remove_file_if_exists(&path)
47            .with_context(|| format!("failed to remove obsolete output {}", path.display()))?
48        {
49            continue;
50        }
51
52        if !quiet {
53            output::detail(&format!("removed obsolete {}", path.display()));
54        }
55
56        if let Some(parent) = path.parent() {
57            prune_empty_owned_parents(output_dir, parent)?;
58        }
59    }
60
61    for (filename, code) in files {
62        let output_file = output_dir.join(filename);
63
64        GeneratedTextFile::new(&output_file, code).persist()?;
65
66        if !quiet {
67            output::detail(&format!("wrote {}", output_file.display()));
68        }
69    }
70
71    write_ownership_manifest(output_dir, &expected_paths)?;
72
73    Ok(())
74}
75
76pub(super) fn check_freshness(
77    files: &[(String, String)],
78    output_dir: &Path,
79) -> anyhow::Result<ExitCode> {
80    let expected_paths = expected_path_set(files)?;
81    let actual_paths = actual_file_set(output_dir)?;
82    let mut stale = BTreeSet::new();
83    let ownership = read_ownership_manifest(output_dir)?;
84
85    if let Some(owned_paths) = &ownership {
86        reject_unowned_files(&actual_paths, owned_paths)?;
87        validate_owned_file_kinds(output_dir, owned_paths)?;
88    } else {
89        let _legacy_paths = adopt_legacy_generated_tree(output_dir, &actual_paths)?;
90
91        stale.insert(PathBuf::from(OWNERSHIP_MANIFEST));
92    }
93
94    for (filename, expected) in files {
95        let path = output_dir.join(filename);
96
97        if GeneratedTextFile::new(&path, expected).status()? != Status::Current {
98            stale.insert(PathBuf::from(filename.as_str()));
99        }
100    }
101
102    stale.extend(actual_paths.difference(&expected_paths).cloned());
103
104    let ownership_path = output_dir.join(OWNERSHIP_MANIFEST);
105
106    if ownership.is_some()
107        && GeneratedTextFile::new(
108            &ownership_path,
109            &ownership_manifest_source(&expected_paths)?,
110        )
111        .status()?
112            != Status::Current
113    {
114        stale.insert(PathBuf::from(OWNERSHIP_MANIFEST));
115    }
116
117    if stale.is_empty() {
118        output::success("generated code is up-to-date");
119
120        Ok(ExitCode::SUCCESS)
121    } else {
122        output::error(&format!(
123            "{} file(s) are stale, run `cargo codegen` to regenerate:",
124            stale.len()
125        ));
126
127        for file in &stale {
128            output::detail(&format!("  {}", file.display()));
129        }
130
131        Ok(ExitCode::FAILURE)
132    }
133}
134
135pub(super) fn expected_path_set(files: &[(String, String)]) -> anyhow::Result<BTreeSet<PathBuf>> {
136    let mut paths = BTreeSet::new();
137
138    for (filename, _) in files {
139        let path = PathBuf::from(filename.as_str());
140
141        validate_owned_path(&path)?;
142
143        if !paths.insert(path) {
144            bail!("generator produced duplicate output path {filename}");
145        }
146    }
147
148    Ok(paths)
149}
150
151pub(super) fn actual_file_set(output_dir: &Path) -> anyhow::Result<BTreeSet<PathBuf>> {
152    let mut paths = BTreeSet::new();
153
154    match directory::inspect_link(output_dir)? {
155        Some(entry) if entry.kind() == EntryKind::Symlink => {
156            bail!(
157                "generated output root must not be a symlink: {}",
158                output_dir.display()
159            );
160        }
161        Some(entry) if entry.kind() != EntryKind::Directory => {
162            bail!(
163                "generated output root is not a directory: {}",
164                output_dir.display()
165            );
166        }
167        Some(_) => {}
168        None => return Ok(paths),
169    }
170
171    collect_actual_files(output_dir, output_dir, &mut paths)?;
172
173    Ok(paths)
174}
175
176fn collect_actual_files(
177    root: &Path,
178    current_directory: &Path,
179    paths: &mut BTreeSet<PathBuf>,
180) -> anyhow::Result<()> {
181    for entry in directory::entries(current_directory).with_context(|| {
182        format!(
183            "failed to read output directory {}",
184            current_directory.display()
185        )
186    })? {
187        if entry.kind() == EntryKind::Symlink {
188            bail!(
189                "generated output tree contains a symlink: {}",
190                entry.path().display()
191            );
192        } else if entry.kind() == EntryKind::Directory {
193            collect_actual_files(root, entry.path(), paths)?;
194        } else if entry.kind() == EntryKind::File {
195            let relative = entry.path().strip_prefix(root).with_context(|| {
196                format!(
197                    "generated output {} is outside {}",
198                    entry.path().display(),
199                    root.display()
200                )
201            })?;
202
203            if relative != Path::new(OWNERSHIP_MANIFEST) {
204                paths.insert(relative.to_path_buf());
205            }
206        } else {
207            bail!(
208                "generated output tree contains a non-regular entry: {}",
209                entry.path().display()
210            );
211        }
212    }
213
214    Ok(())
215}