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    // release-please stays a predecessor: the python binding no longer
75    // lands it, and a target an earlier release landed it in, or that ran
76    // it before release-kit, still carries these files.
77    (
78        "release-please-config.json",
79        "release-please",
80        Kind::ReleaseConfig,
81    ),
82    (
83        ".release-please-manifest.json",
84        "release-please",
85        Kind::Manifest,
86    ),
87    (
88        ".github/workflows/release-please.yml",
89        "release-please",
90        Kind::Workflow,
91    ),
92    (
93        ".github/workflows/release-please.yaml",
94        "release-please",
95        Kind::Workflow,
96    ),
97    (".releaserc", "semantic-release", Kind::ReleaseConfig),
98    (".releaserc.json", "semantic-release", Kind::ReleaseConfig),
99    (".releaserc.yaml", "semantic-release", Kind::ReleaseConfig),
100    (".releaserc.yml", "semantic-release", Kind::ReleaseConfig),
101    (".releaserc.js", "semantic-release", Kind::ReleaseConfig),
102    (".releaserc.cjs", "semantic-release", Kind::ReleaseConfig),
103    (".releaserc.mjs", "semantic-release", Kind::ReleaseConfig),
104    ("release.config.js", "semantic-release", Kind::ReleaseConfig),
105    (
106        "release.config.cjs",
107        "semantic-release",
108        Kind::ReleaseConfig,
109    ),
110    (
111        "release.config.mjs",
112        "semantic-release",
113        Kind::ReleaseConfig,
114    ),
115    (
116        ".github/workflows/semantic-release.yml",
117        "semantic-release",
118        Kind::Workflow,
119    ),
120    (".goreleaser.yaml", "goreleaser", Kind::ReleaseConfig),
121    (".goreleaser.yml", "goreleaser", Kind::ReleaseConfig),
122    ("goreleaser.yaml", "goreleaser", Kind::ReleaseConfig),
123    ("goreleaser.yml", "goreleaser", Kind::ReleaseConfig),
124    (".config/goreleaser.yaml", "goreleaser", Kind::ReleaseConfig),
125    (".config/goreleaser.yml", "goreleaser", Kind::ReleaseConfig),
126    (
127        ".github/workflows/goreleaser.yml",
128        "goreleaser",
129        Kind::Workflow,
130    ),
131    ("release-plz.toml", "release-plz", Kind::ReleaseConfig),
132    (".release-plz.toml", "release-plz", Kind::ReleaseConfig),
133    (
134        ".github/workflows/release-plz.yml",
135        "release-plz",
136        Kind::Workflow,
137    ),
138    ("cliff.toml", "git-cliff", Kind::ReleaseConfig),
139    ("dist-workspace.toml", "cargo-dist", Kind::ReleaseConfig),
140    (
141        ".github/workflows/release.yml",
142        "cargo-dist",
143        Kind::Workflow,
144    ),
145    (".changeset/config.json", "changesets", Kind::ReleaseConfig),
146    (
147        ".github/workflows/changesets.yml",
148        "changesets",
149        Kind::Workflow,
150    ),
151    (".bumpversion.cfg", "bump-my-version", Kind::ReleaseConfig),
152    (".bumpversion.toml", "bump-my-version", Kind::ReleaseConfig),
153    (".cz.toml", "commitizen", Kind::ReleaseConfig),
154    (".cz.json", "commitizen", Kind::ReleaseConfig),
155    (".cz.yaml", "commitizen", Kind::ReleaseConfig),
156    (".versionrc", "standard-version", Kind::ReleaseConfig),
157    (".versionrc.json", "standard-version", Kind::ReleaseConfig),
158    (
159        ".github/release-drafter.yml",
160        "release-drafter",
161        Kind::ReleaseConfig,
162    ),
163    (
164        ".github/workflows/release-drafter.yml",
165        "release-drafter",
166        Kind::Workflow,
167    ),
168    ("commitlint.config.js", "commitlint", Kind::Hook),
169    ("commitlint.config.cjs", "commitlint", Kind::Hook),
170    (".commitlintrc", "commitlint", Kind::Hook),
171    (".commitlintrc.json", "commitlint", Kind::Hook),
172    (".commitlintrc.yml", "commitlint", Kind::Hook),
173    (".husky/commit-msg", "husky", Kind::Hook),
174    (
175        ".github/workflows/publish.yml",
176        "hand-rolled",
177        Kind::Workflow,
178    ),
179    (
180        ".github/workflows/publish.yaml",
181        "hand-rolled",
182        Kind::Workflow,
183    ),
184    (
185        ".github/workflows/release.yaml",
186        "hand-rolled",
187        Kind::Workflow,
188    ),
189    ("release.sh", "hand-rolled", Kind::BumpScript),
190    ("bump.sh", "hand-rolled", Kind::BumpScript),
191    ("scripts/release.sh", "hand-rolled", Kind::BumpScript),
192    ("scripts/bump-version.sh", "hand-rolled", Kind::BumpScript),
193];
194
195/// The root files that stay in place and are listed as such.
196pub const ROOT_FILES: [&str; 4] = ["CHANGELOG.md", "README.md", "LICENSE", "AGENTS.md"];
197
198/// One listed file.
199#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
200pub struct Entry {
201    /// The target-relative path.
202    pub path: String,
203    /// The tool that owns it, or `project` for a root file.
204    pub tool: &'static str,
205    /// What it is to its tool.
206    pub kind: Kind,
207    /// Whether the archive moves it.
208    pub placement: Placement,
209    /// The sha256 of its bytes.
210    pub sha256: String,
211}
212
213/// The tool, kind, and placement of one path, or `None` for a path this
214/// release does not list.
215#[must_use]
216pub fn classify(path: &str) -> Option<(&'static str, Kind, Placement)> {
217    if ROOT_FILES.contains(&path) {
218        return Some(("project", Kind::Root, Placement::InPlace));
219    }
220    PREDECESSORS
221        .iter()
222        .find(|(known, _, _)| *known == path)
223        .map(|(_, tool, kind)| (*tool, *kind, Placement::Archived))
224}
225
226/// Every listed path among `paths`, predecessor files first in table
227/// order, then the root files.
228#[must_use]
229pub fn listed<'a>(paths: &[&'a str]) -> Vec<&'a str> {
230    PREDECESSORS
231        .iter()
232        .map(|(known, _, _)| *known)
233        .chain(ROOT_FILES)
234        .filter_map(|known| paths.iter().find(|path| **path == known).copied())
235        .collect()
236}
237
238/// Every listed file present at `target`, read from the disk.
239///
240/// # Errors
241///
242/// [`RkError::Io`] where a present file cannot be read.
243pub fn scan(target: &Utf8Path) -> Result<Vec<Entry>, RkError> {
244    let present: Vec<&str> = PREDECESSORS
245        .iter()
246        .map(|(path, _, _)| *path)
247        .chain(ROOT_FILES)
248        .filter(|path| {
249            let full = target.join(path);
250            full.is_file() && !full.is_symlink()
251        })
252        .collect();
253    let mut out = Vec::new();
254    for path in listed(&present) {
255        let bytes = std::fs::read(target.join(path))?;
256        let (tool, kind, placement) =
257            classify(path).unwrap_or(("project", Kind::Root, Placement::InPlace));
258        out.push(Entry {
259            path: path.to_owned(),
260            tool,
261            kind,
262            placement,
263            sha256: Digest::of(&bytes).to_string(),
264        });
265    }
266    Ok(out)
267}
268
269#[cfg(test)]
270mod tests {
271    use super::{Kind, PREDECESSORS, Placement, ROOT_FILES, classify, listed};
272
273    #[test]
274    fn every_predecessor_path_is_listed_once_and_no_root_file_is_archived() {
275        let mut seen = std::collections::BTreeSet::new();
276        for (path, _, kind) in PREDECESSORS {
277            assert!(seen.insert(path), "{path} is listed twice");
278            assert_ne!(kind, Kind::Root, "{path}");
279            assert!(!ROOT_FILES.contains(&path), "{path}");
280        }
281        for root in ROOT_FILES {
282            assert_eq!(
283                classify(root),
284                Some(("project", Kind::Root, Placement::InPlace))
285            );
286        }
287        assert_eq!(
288            classify("release-please-config.json"),
289            Some(("release-please", Kind::ReleaseConfig, Placement::Archived))
290        );
291        assert_eq!(classify("src/main.rs"), None);
292    }
293
294    #[test]
295    fn the_listing_keeps_the_table_order() {
296        let paths = [
297            "README.md",
298            "cliff.toml",
299            "release-please-config.json",
300            "src/lib.rs",
301        ];
302        assert_eq!(
303            listed(&paths),
304            ["release-please-config.json", "cliff.toml", "README.md"]
305        );
306    }
307}