Skip to main content

codegen/app/
ownership.rs

1use std::collections::BTreeSet;
2
3use anyhow::{Context, bail};
4use wowlab_fs::{
5    artifact::GeneratedTextFile,
6    directory::{self, EntryKind},
7    file,
8    path::{Component, Path, PathBuf},
9};
10
11pub(super) const OWNERSHIP_MANIFEST: &str = ".wowlab-codegen-ownership";
12const OWNERSHIP_MAGIC: &str = "# wowlab-codegen-output-v1";
13pub(super) const GENERATED_HEADER: &str = "//! @generated by codegen-cli";
14
15pub(super) fn validate_owned_path(path: &Path) -> anyhow::Result<()> {
16    let valid = !path.as_os_str().is_empty()
17        && path != Path::new(OWNERSHIP_MANIFEST)
18        && path
19            .components()
20            .all(|component| matches!(component, Component::Normal(_)));
21
22    if valid {
23        Ok(())
24    } else {
25        bail!("invalid generated output path {}", path.display())
26    }
27}
28
29pub(super) fn ownership_manifest_source(paths: &BTreeSet<PathBuf>) -> anyhow::Result<String> {
30    let mut source = String::from(OWNERSHIP_MAGIC);
31
32    source.push('\n');
33
34    for path in paths {
35        validate_owned_path(path)?;
36        let path = path
37            .to_str()
38            .with_context(|| format!("generated output path is not UTF-8: {}", path.display()))?;
39
40        source.push_str(path);
41        source.push('\n');
42    }
43
44    Ok(source)
45}
46
47pub(super) fn write_ownership_manifest(
48    output_dir: &Path,
49    paths: &BTreeSet<PathBuf>,
50) -> anyhow::Result<()> {
51    let source = ownership_manifest_source(paths)?;
52
53    directory::ensure(output_dir).with_context(|| {
54        format!(
55            "failed to create generated output directory {}",
56            output_dir.display()
57        )
58    })?;
59    let path = output_dir.join(OWNERSHIP_MANIFEST);
60
61    GeneratedTextFile::new(&path, &source)
62        .persist()
63        .with_context(|| {
64            format!(
65                "failed to atomically replace ownership manifest {}",
66                path.display()
67            )
68        })
69}
70
71pub(super) fn read_ownership_manifest(
72    output_dir: &Path,
73) -> anyhow::Result<Option<BTreeSet<PathBuf>>> {
74    let path = output_dir.join(OWNERSHIP_MANIFEST);
75    let Some(source) = file::read_text_if_exists(&path)
76        .with_context(|| format!("failed to read ownership manifest {}", path.display()))?
77    else {
78        return Ok(None);
79    };
80    let mut lines = source.lines();
81
82    if lines.next() != Some(OWNERSHIP_MAGIC) {
83        bail!(
84            "invalid codegen ownership manifest header: {}",
85            path.display()
86        );
87    }
88
89    let mut paths = BTreeSet::new();
90
91    for line in lines {
92        let owned_path = PathBuf::from(line);
93
94        validate_owned_path(&owned_path)
95            .with_context(|| format!("invalid entry in ownership manifest {}", path.display()))?;
96
97        if !paths.insert(owned_path) {
98            bail!(
99                "duplicate entry `{line}` in ownership manifest {}",
100                path.display()
101            );
102        }
103    }
104
105    Ok(Some(paths))
106}
107
108pub(super) fn reject_unowned_files(
109    actual_paths: &BTreeSet<PathBuf>,
110    owned_paths: &BTreeSet<PathBuf>,
111) -> anyhow::Result<()> {
112    if let Some(unowned) = actual_paths.difference(owned_paths).next() {
113        bail!(
114            "refusing to modify generated output tree containing unowned file {}; move it outside the output directory or add it through codegen",
115            unowned.display()
116        );
117    }
118
119    Ok(())
120}
121
122pub(super) fn validate_owned_file_kinds(
123    output_dir: &Path,
124    owned_paths: &BTreeSet<PathBuf>,
125) -> anyhow::Result<()> {
126    for owned in owned_paths {
127        let path = output_dir.join(owned);
128
129        match directory::inspect_link(&path)
130            .with_context(|| format!("failed to inspect owned output {}", path.display()))?
131            .map(|entry| entry.kind())
132        {
133            Some(EntryKind::File) | None => {}
134            Some(_) => bail!(
135                "owned generated output is not a regular file: {}",
136                path.display()
137            ),
138        }
139    }
140
141    Ok(())
142}
143
144pub(super) fn adopt_legacy_generated_tree(
145    output_dir: &Path,
146    actual_paths: &BTreeSet<PathBuf>,
147) -> anyhow::Result<BTreeSet<PathBuf>> {
148    for relative in actual_paths {
149        let path = output_dir.join(relative);
150        let source = file::read_text(&path).with_context(|| {
151            format!(
152                "failed to inspect legacy generated output {}",
153                path.display()
154            )
155        })?;
156
157        if !source.starts_with(GENERATED_HEADER) {
158            bail!(
159                "refusing to adopt output tree without an ownership manifest because {} is not a recognizable codegen artifact",
160                path.display()
161            );
162        }
163    }
164
165    Ok(actual_paths.clone())
166}
167
168pub(super) fn prune_empty_owned_parents(root: &Path, start_directory: &Path) -> anyhow::Result<()> {
169    let mut current = start_directory;
170
171    while current != root {
172        match directory::remove_if_empty(current) {
173            Ok(()) => {}
174            Err(error) if error.is_not_found() || error.is_directory_not_empty() => {
175                break;
176            }
177            Err(error) => {
178                return Err(error).with_context(|| {
179                    format!("failed to prune output directory {}", current.display())
180                });
181            }
182        }
183
184        current = current.parent().with_context(|| {
185            format!(
186                "generated output directory escaped ownership root {}",
187                root.display()
188            )
189        })?;
190    }
191
192    Ok(())
193}