xtask/cmd/change_logs/
generate.rs

1use std::collections::{HashMap, HashSet};
2
3use anyhow::Context;
4use cargo_metadata::camino::Utf8Path;
5
6use super::util::{Fragment, PackageChangeLog};
7use crate::cmd::IGNORED_PACKAGES;
8
9#[derive(Debug, Clone, clap::Parser)]
10pub struct Generate {
11    #[clap(long, short, value_delimiter = ',')]
12    #[clap(alias = "package")]
13    /// Packages to test
14    packages: Vec<String>,
15    #[clap(long, short, value_delimiter = ',')]
16    #[clap(alias = "exclude-package")]
17    /// Packages to exclude from testing
18    exclude_packages: Vec<String>,
19}
20
21const CHANGE_LOG_HEADER: &str = "# Changelog
22
23<!--
24This file is automatically generated by our release process.
25DO NOT edit it directly.
26If you want to add a change log entry for this package,
27please create a new file in /changes.d/<pr-number>.toml
28Refer to the [README.md](/changes.d/README.md) for more information.
29-->
30
31All notable changes to this project will be documented in this file.
32
33The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
34and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
35
36## [Unreleased]
37";
38
39fn update_change_log(logs: &[PackageChangeLog], manifest_path: &Utf8Path) -> anyhow::Result<()> {
40    let change_log_path_md = manifest_path.with_file_name("CHANGELOG.md");
41
42    let mut change_log = if !change_log_path_md.exists() {
43        CHANGE_LOG_HEADER.to_string()
44    } else {
45        std::fs::read_to_string(&change_log_path_md).context("failed to read CHANGELOG.md")?
46    };
47
48    // Find the # [Unreleased] section
49    // So we can insert the new logs after it
50    let mut breaking_changes = logs.iter().filter(|log| log.breaking).collect::<Vec<_>>();
51    breaking_changes.sort_by_key(|log| &log.category);
52    let mut other_changes = logs.iter().filter(|log| !log.breaking).collect::<Vec<_>>();
53    other_changes.sort_by_key(|log| &log.category);
54
55    fn make_logs(logs: &[&PackageChangeLog]) -> String {
56        logs.iter()
57            .map(
58                |entry| {
59                    format!(
60                        "- {category}: {description} ([#{pr_number}](https://github.com/scufflecloud/scuffle/pull/{pr_number})){authors}",
61                        category = entry.category,
62                        description = entry.description,
63                        pr_number = entry.pr_number,
64                        authors = if !entry.authors.is_empty() {
65                            format!(" ({})", entry.authors.join(", "))
66                        } else {
67                            String::new()
68                        },
69                    )
70                 }
71            )
72            .collect::<Vec<_>>()
73            .join("\n")
74    }
75
76    let breaking_changes = make_logs(&breaking_changes);
77    let other_changes = make_logs(&other_changes);
78
79    let mut replaced = String::new();
80
81    replaced.push_str("## [Unreleased]\n");
82    if !breaking_changes.is_empty() {
83        replaced.push_str("\n### ⚠️ Breaking changes\n\n");
84        replaced.push_str(&breaking_changes);
85        replaced.push('\n');
86    }
87
88    if !other_changes.is_empty() {
89        replaced.push_str("\n### 🛠️ Non-breaking changes\n\n");
90        replaced.push_str(&other_changes);
91        replaced.push('\n');
92    }
93
94    change_log = change_log.replace("## [Unreleased]\n", &replaced);
95
96    std::fs::write(&change_log_path_md, change_log).context("failed to write CHANGELOG.md")?;
97
98    Ok(())
99}
100
101fn generate_change_logs(
102    package: &str,
103    change_fragments: &mut HashMap<u64, Fragment>,
104) -> anyhow::Result<Vec<PackageChangeLog>> {
105    let mut logs = Vec::new();
106
107    for fragment in change_fragments.values_mut() {
108        logs.extend(fragment.remove_package(package).context("parse")?);
109    }
110
111    Ok(logs)
112}
113
114fn save_change_fragments(fragments: &mut HashMap<u64, Fragment>) -> anyhow::Result<()> {
115    fragments
116        .values_mut()
117        .filter(|fragment| fragment.changed)
118        .try_for_each(|fragment| fragment.save().context("save"))?;
119
120    fragments.retain(|_, fragment| !fragment.deleted);
121
122    Ok(())
123}
124
125impl Generate {
126    pub fn run(self) -> anyhow::Result<()> {
127        let start = std::time::Instant::now();
128
129        let metadata = crate::utils::metadata()?;
130
131        let workspace_package_ids = metadata.workspace_members.iter().cloned().collect::<HashSet<_>>();
132
133        let path = metadata.workspace_root.join("changes.d");
134
135        eprintln!("reading {path}");
136
137        let mut change_fragments = std::fs::read_dir(&path)?
138            .filter_map(|entry| entry.ok())
139            .filter_map(|entry| {
140                let path = entry.path();
141                if path.is_file() {
142                    let pr_number = path
143                        .file_name()?
144                        .to_str()?
145                        .strip_prefix("pr-")?
146                        .strip_suffix(".toml")?
147                        .parse()
148                        .ok()?;
149
150                    Some((pr_number, path))
151                } else {
152                    None
153                }
154            })
155            .try_fold(HashMap::new(), |mut fragments, (pr_number, path)| {
156                let fragment = Fragment::new(pr_number, &path).with_context(|| path.display().to_string())?;
157
158                fragments.insert(pr_number, fragment);
159
160                anyhow::Ok(fragments)
161            })?;
162
163        let packages = metadata
164            .packages
165            .iter()
166            .filter(|p| workspace_package_ids.contains(&p.id) && !IGNORED_PACKAGES.contains(&p.name.as_str()))
167            .filter(|p| self.packages.is_empty() || self.packages.contains(&p.name))
168            .filter(|p| self.exclude_packages.is_empty() || !self.exclude_packages.contains(&p.name))
169            .collect::<Vec<_>>();
170
171        for package in &self.packages {
172            anyhow::ensure!(packages.iter().any(|p| p.name == *package), "Package {} not found", package);
173        }
174
175        for package in packages {
176            let change_logs = generate_change_logs(package.name.as_str(), &mut change_fragments).context("generate")?;
177            if !change_logs.is_empty() {
178                update_change_log(&change_logs, &package.manifest_path).context("update")?;
179                save_change_fragments(&mut change_fragments).context("save")?;
180                eprintln!("Updated change logs for {}", package.name);
181            }
182        }
183
184        eprintln!("Done in {:?}", start.elapsed());
185
186        Ok(())
187    }
188}