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