Skip to main content

release_kit/migrate/
inventory.rs

1//! The one detector of predecessor files.
2//!
3//! A predecessor file is one another release tool owns: its release
4//! configuration, its workflow, its manifest, a bump script, or a hook.
5//! Each is named here with its tool and its kind, so the archive moves
6//! exactly what this table lists and nothing a guess would add. Four root
7//! files serve the project whatever releases it, so they stay in place
8//! and are listed as such: the changelog, the readme, the licence, and the
9//! agent entry document.
10
11use camino::Utf8Path;
12use serde::{Deserialize, Serialize};
13
14use crate::digest::Digest;
15use crate::error::RkError;
16
17/// What a predecessor file is to its tool.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "kebab-case")]
20pub enum Kind {
21    /// A CI workflow that runs the tool.
22    Workflow,
23    /// The tool's own configuration.
24    ReleaseConfig,
25    /// A script that moves a version by hand.
26    BumpScript,
27    /// A hook or the configuration a hook reads.
28    Hook,
29    /// A file the tool writes to remember a version.
30    Manifest,
31    /// A root file that stays in place.
32    Root,
33}
34
35impl Kind {
36    /// The kebab-case word, as the JSON serializes it.
37    #[must_use]
38    pub const fn as_str(self) -> &'static str {
39        match self {
40            Self::Workflow => "workflow",
41            Self::ReleaseConfig => "release-config",
42            Self::BumpScript => "bump-script",
43            Self::Hook => "hook",
44            Self::Manifest => "manifest",
45            Self::Root => "root",
46        }
47    }
48}
49
50/// Where a listed file stays through the archive.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "kebab-case")]
53pub enum Placement {
54    /// The archive moves it into the workspace.
55    Archived,
56    /// It stays at its path in the target.
57    InPlace,
58}
59
60impl Placement {
61    /// The kebab-case word, as the JSON serializes it.
62    #[must_use]
63    pub const fn as_str(self) -> &'static str {
64        match self {
65            Self::Archived => "archived",
66            Self::InPlace => "in-place",
67        }
68    }
69}
70
71/// Every predecessor file this release recognizes: its path, its tool,
72/// and its kind.
73pub const PREDECESSORS: [(&str, &str, Kind); 52] = [
74    (
75        "release-please-config.json",
76        "release-please",
77        Kind::ReleaseConfig,
78    ),
79    (
80        ".release-please-manifest.json",
81        "release-please",
82        Kind::Manifest,
83    ),
84    (
85        ".github/workflows/release-please.yml",
86        "release-please",
87        Kind::Workflow,
88    ),
89    (
90        ".github/workflows/release-please.yaml",
91        "release-please",
92        Kind::Workflow,
93    ),
94    (".releaserc", "semantic-release", Kind::ReleaseConfig),
95    (".releaserc.json", "semantic-release", Kind::ReleaseConfig),
96    (".releaserc.yaml", "semantic-release", Kind::ReleaseConfig),
97    (".releaserc.yml", "semantic-release", Kind::ReleaseConfig),
98    (".releaserc.js", "semantic-release", Kind::ReleaseConfig),
99    (".releaserc.cjs", "semantic-release", Kind::ReleaseConfig),
100    (".releaserc.mjs", "semantic-release", Kind::ReleaseConfig),
101    ("release.config.js", "semantic-release", Kind::ReleaseConfig),
102    (
103        "release.config.cjs",
104        "semantic-release",
105        Kind::ReleaseConfig,
106    ),
107    (
108        "release.config.mjs",
109        "semantic-release",
110        Kind::ReleaseConfig,
111    ),
112    (
113        ".github/workflows/semantic-release.yml",
114        "semantic-release",
115        Kind::Workflow,
116    ),
117    (".goreleaser.yaml", "goreleaser", Kind::ReleaseConfig),
118    (".goreleaser.yml", "goreleaser", Kind::ReleaseConfig),
119    ("goreleaser.yaml", "goreleaser", Kind::ReleaseConfig),
120    ("goreleaser.yml", "goreleaser", Kind::ReleaseConfig),
121    (".config/goreleaser.yaml", "goreleaser", Kind::ReleaseConfig),
122    (".config/goreleaser.yml", "goreleaser", Kind::ReleaseConfig),
123    (
124        ".github/workflows/goreleaser.yml",
125        "goreleaser",
126        Kind::Workflow,
127    ),
128    ("release-plz.toml", "release-plz", Kind::ReleaseConfig),
129    (".release-plz.toml", "release-plz", Kind::ReleaseConfig),
130    (
131        ".github/workflows/release-plz.yml",
132        "release-plz",
133        Kind::Workflow,
134    ),
135    ("cliff.toml", "git-cliff", Kind::ReleaseConfig),
136    ("dist-workspace.toml", "cargo-dist", Kind::ReleaseConfig),
137    (
138        ".github/workflows/release.yml",
139        "cargo-dist",
140        Kind::Workflow,
141    ),
142    (".changeset/config.json", "changesets", Kind::ReleaseConfig),
143    (
144        ".github/workflows/changesets.yml",
145        "changesets",
146        Kind::Workflow,
147    ),
148    (".bumpversion.cfg", "bump-my-version", Kind::ReleaseConfig),
149    (".bumpversion.toml", "bump-my-version", Kind::ReleaseConfig),
150    (".cz.toml", "commitizen", Kind::ReleaseConfig),
151    (".cz.json", "commitizen", Kind::ReleaseConfig),
152    (".cz.yaml", "commitizen", Kind::ReleaseConfig),
153    (".versionrc", "standard-version", Kind::ReleaseConfig),
154    (".versionrc.json", "standard-version", Kind::ReleaseConfig),
155    (
156        ".github/release-drafter.yml",
157        "release-drafter",
158        Kind::ReleaseConfig,
159    ),
160    (
161        ".github/workflows/release-drafter.yml",
162        "release-drafter",
163        Kind::Workflow,
164    ),
165    ("commitlint.config.js", "commitlint", Kind::Hook),
166    ("commitlint.config.cjs", "commitlint", Kind::Hook),
167    (".commitlintrc", "commitlint", Kind::Hook),
168    (".commitlintrc.json", "commitlint", Kind::Hook),
169    (".commitlintrc.yml", "commitlint", Kind::Hook),
170    (".husky/commit-msg", "husky", Kind::Hook),
171    (
172        ".github/workflows/publish.yml",
173        "hand-rolled",
174        Kind::Workflow,
175    ),
176    (
177        ".github/workflows/publish.yaml",
178        "hand-rolled",
179        Kind::Workflow,
180    ),
181    (
182        ".github/workflows/release.yaml",
183        "hand-rolled",
184        Kind::Workflow,
185    ),
186    ("release.sh", "hand-rolled", Kind::BumpScript),
187    ("bump.sh", "hand-rolled", Kind::BumpScript),
188    ("scripts/release.sh", "hand-rolled", Kind::BumpScript),
189    ("scripts/bump-version.sh", "hand-rolled", Kind::BumpScript),
190];
191
192/// The root files that stay in place and are listed as such.
193pub const ROOT_FILES: [&str; 4] = ["CHANGELOG.md", "README.md", "LICENSE", "AGENTS.md"];
194
195/// One listed file.
196#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
197pub struct Entry {
198    /// The target-relative path.
199    pub path: String,
200    /// The tool that owns it, or `project` for a root file.
201    pub tool: &'static str,
202    /// What it is to its tool.
203    pub kind: Kind,
204    /// Whether the archive moves it.
205    pub placement: Placement,
206    /// The sha256 of its bytes.
207    pub sha256: String,
208}
209
210/// The tool, kind, and placement of one path, or `None` for a path this
211/// release does not list.
212#[must_use]
213pub fn classify(path: &str) -> Option<(&'static str, Kind, Placement)> {
214    if ROOT_FILES.contains(&path) {
215        return Some(("project", Kind::Root, Placement::InPlace));
216    }
217    PREDECESSORS
218        .iter()
219        .find(|(known, _, _)| *known == path)
220        .map(|(_, tool, kind)| (*tool, *kind, Placement::Archived))
221}
222
223/// Every listed path among `paths`, predecessor files first in table
224/// order, then the root files.
225#[must_use]
226pub fn listed<'a>(paths: &[&'a str]) -> Vec<&'a str> {
227    PREDECESSORS
228        .iter()
229        .map(|(known, _, _)| *known)
230        .chain(ROOT_FILES)
231        .filter_map(|known| paths.iter().find(|path| **path == known).copied())
232        .collect()
233}
234
235/// Every listed file present at `target`, read from the disk.
236///
237/// # Errors
238///
239/// [`RkError::Io`] where a present file cannot be read.
240pub fn scan(target: &Utf8Path) -> Result<Vec<Entry>, RkError> {
241    let present: Vec<&str> = PREDECESSORS
242        .iter()
243        .map(|(path, _, _)| *path)
244        .chain(ROOT_FILES)
245        .filter(|path| {
246            let full = target.join(path);
247            full.is_file() && !full.is_symlink()
248        })
249        .collect();
250    let mut out = Vec::new();
251    for path in listed(&present) {
252        let bytes = std::fs::read(target.join(path))?;
253        let (tool, kind, placement) =
254            classify(path).unwrap_or(("project", Kind::Root, Placement::InPlace));
255        out.push(Entry {
256            path: path.to_owned(),
257            tool,
258            kind,
259            placement,
260            sha256: Digest::of(&bytes).to_string(),
261        });
262    }
263    Ok(out)
264}
265
266#[cfg(test)]
267mod tests {
268    use super::{Kind, PREDECESSORS, Placement, ROOT_FILES, classify, listed};
269
270    #[test]
271    fn every_predecessor_path_is_listed_once_and_no_root_file_is_archived() {
272        let mut seen = std::collections::BTreeSet::new();
273        for (path, _, kind) in PREDECESSORS {
274            assert!(seen.insert(path), "{path} is listed twice");
275            assert_ne!(kind, Kind::Root, "{path}");
276            assert!(!ROOT_FILES.contains(&path), "{path}");
277        }
278        for root in ROOT_FILES {
279            assert_eq!(
280                classify(root),
281                Some(("project", Kind::Root, Placement::InPlace))
282            );
283        }
284        assert_eq!(
285            classify("release-please-config.json"),
286            Some(("release-please", Kind::ReleaseConfig, Placement::Archived))
287        );
288        assert_eq!(classify("src/main.rs"), None);
289    }
290
291    #[test]
292    fn the_listing_keeps_the_table_order() {
293        let paths = [
294            "README.md",
295            "cliff.toml",
296            "release-please-config.json",
297            "src/lib.rs",
298        ];
299        assert_eq!(
300            listed(&paths),
301            ["release-please-config.json", "cliff.toml", "README.md"]
302        );
303    }
304}