xtask/cmd/release/
update.rs

1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
2use std::fmt::Write;
3
4use anyhow::Context;
5use cargo_metadata::camino::{Utf8Path, Utf8PathBuf};
6use cargo_metadata::semver::Version;
7use cargo_metadata::{DependencyKind, semver};
8use serde::Deserialize as _;
9use serde::de::IntoDeserializer;
10use serde_derive::{Deserialize, Serialize};
11use toml_edit::{DocumentMut, Table};
12
13use super::check::CheckRun;
14use super::utils::VersionBump;
15use crate::cmd::release::utils::vers_to_comp;
16use crate::utils::git_workdir_clean;
17
18#[derive(Debug, Clone, clap::Parser)]
19pub struct Update {
20    /// Concurrency to run at. By default, this is the total number of cpus on the host.
21    #[arg(long, default_value_t = num_cpus::get())]
22    concurrency: usize,
23    /// Run the command without modifying any files on disk
24    #[arg(long)]
25    dry_run: bool,
26    /// Allow the command to execute even if there are uncomitted changes in the workspace
27    #[arg(long)]
28    allow_dirty: bool,
29    /// Packages to include in the check
30    /// by default all packages are included
31    #[arg(long = "package", short = 'p')]
32    packages: Vec<String>,
33    /// Only generate the changelogs, not the version bumps.
34    #[arg(long)]
35    changelogs_only: bool,
36}
37
38impl Update {
39    pub fn run(self) -> anyhow::Result<()> {
40        if !self.allow_dirty {
41            git_workdir_clean()?;
42        }
43
44        let metadata = crate::utils::metadata()?;
45
46        let check_run = CheckRun::new(&metadata, &self.packages).context("check run")?;
47
48        let mut change_fragments = std::fs::read_dir(metadata.workspace_root.join("changes.d"))?
49            .filter_map(|entry| entry.ok())
50            .filter_map(|entry| {
51                let entry_path = entry.path();
52                if entry_path.is_file() {
53                    let file_name = entry_path.file_name()?.to_str()?;
54                    file_name.strip_prefix("pr-")?.strip_suffix(".toml")?.parse().ok()
55                } else {
56                    None
57                }
58            })
59            .try_fold(BTreeMap::new(), |mut fragments, pr_number| {
60                let fragment = Fragment::new(pr_number, &metadata.workspace_root)?;
61
62                fragments.insert(pr_number, fragment);
63
64                anyhow::Ok(fragments)
65            })?;
66
67        let dep_graph = check_run
68            .all_packages()
69            .map(|package| {
70                (
71                    package.name.as_str(),
72                    package
73                        .dependencies
74                        .iter()
75                        .filter(|dep| {
76                            dep.path.is_some() && matches!(dep.kind, DependencyKind::Build | DependencyKind::Normal)
77                        })
78                        .map(|dep| (dep.name.as_str(), dep))
79                        .collect::<BTreeMap<_, _>>(),
80                )
81            })
82            .collect::<HashMap<_, _>>();
83
84        let inverted_dep_graph = dep_graph
85            .iter()
86            .fold(HashMap::<_, Vec<_>>::new(), |mut inverted, (package, deps)| {
87                deps.iter().for_each(|(name, dep)| {
88                    inverted.entry(*name).or_default().push((*package, *dep));
89                });
90                inverted
91            });
92
93        let flattened_dep_public_graph = dep_graph
94            .iter()
95            .map(|(package, deps)| {
96                let mut seen = HashSet::new();
97                let pkg = check_run.get_package(package).unwrap();
98                (
99                    *package,
100                    deps.iter().fold(HashMap::<_, Vec<_>>::new(), |mut deps, (name, dep)| {
101                        let mut stack = vec![(pkg, check_run.get_package(name).unwrap(), *dep)];
102
103                        while let Some((pkg, dep_pkg, dep)) = stack.pop() {
104                            if pkg.is_dep_public(&dep.name) {
105                                deps.entry(dep_pkg.name.as_str()).or_default().push(dep);
106                                if seen.insert(&dep_pkg.name) {
107                                    stack.extend(
108                                        dep_graph
109                                            .get(dep_pkg.name.as_str())
110                                            .into_iter()
111                                            .flatten()
112                                            .map(|(name, dep)| (pkg, check_run.get_package(name).unwrap(), *dep)),
113                                    );
114                                }
115                            }
116                        }
117
118                        deps
119                    }),
120                )
121            })
122            .collect::<HashMap<_, _>>();
123
124        if !self.changelogs_only {
125            check_run.process(self.concurrency, &metadata.workspace_root, None)?;
126
127            for fragment in change_fragments.values() {
128                for (package, logs) in fragment.items().context("fragment items")? {
129                    let Some(pkg) = check_run.get_package(&package) else {
130                        tracing::warn!("unknown package: {package}");
131                        continue;
132                    };
133
134                    pkg.report_change();
135                    if logs.iter().any(|l| l.breaking) {
136                        pkg.report_breaking_change();
137                    }
138                }
139            }
140
141            let mut found = false;
142            for iter in 0..10 {
143                let mut has_changes = false;
144                for group in check_run.all_groups() {
145                    let max_bump_version = group
146                        .iter()
147                        .map(|p| {
148                            match (p.last_published_version(), p.version_bump()) {
149                                // There has never been a published version
150                                // or there is no bump
151                                (None, _) | (_, None) => p.version.clone(),
152                                // The last published version is the current version
153                                (Some(last_published), Some(bump)) if last_published.vers == p.version => {
154                                    bump.next_semver(p.version.clone())
155                                }
156                                // Last published version is a different version
157                                (Some(last_published), Some(bump)) => {
158                                    // determine if the last published version is a minor or major version away.
159                                    if bump == VersionBump::Major
160                                        && !vers_to_comp(last_published.vers.clone()).matches(&p.version)
161                                    {
162                                        bump.next_semver(last_published.vers)
163                                    } else {
164                                        p.version.clone()
165                                    }
166                                }
167                            }
168                        })
169                        .max()
170                        .unwrap();
171
172                    group
173                        .iter()
174                        .filter(|package| package.version != max_bump_version)
175                        .for_each(|package| {
176                            inverted_dep_graph
177                                .get(package.name.as_ref())
178                                .into_iter()
179                                .flatten()
180                                .for_each(|(pkg, dep)| {
181                                    if !dep.req.matches(&max_bump_version) || dep.req == package.unreleased_req() {
182                                        let pkg = check_run.get_package(pkg).unwrap();
183                                        if pkg.is_dep_public(&dep.name) && pkg.group() != package.group() {
184                                            pkg.report_breaking_change();
185                                        } else {
186                                            pkg.report_change();
187                                        }
188                                    }
189                                });
190                        });
191
192                    group.iter().for_each(|p| {
193                        if p.version != max_bump_version && p.next_version().is_none_or(|v| v != max_bump_version) {
194                            tracing::debug!("{} to {} -> {max_bump_version}", p.name, p.version);
195                            p.set_next_version(max_bump_version.clone());
196                            has_changes = true;
197                        }
198                    });
199                }
200
201                if !has_changes {
202                    tracing::debug!("satisfied version constraints after {} iterations", iter + 1);
203                    found = true;
204                    break;
205                }
206            }
207
208            if !found {
209                anyhow::bail!("could not satisfy version constraints after 10 attempts");
210            }
211
212            for package in check_run.groups().flatten() {
213                let deps = dep_graph.get(package.name.as_str()).unwrap();
214                for dep in deps.values() {
215                    let dep_pkg = check_run.get_package(&dep.name).unwrap();
216
217                    let depends_on = dep.req == dep_pkg.unreleased_req()
218                        || dep_pkg.last_published_version().is_none_or(|v| !dep.req.matches(&v.vers))
219                        // we want to find out if any deps have a major semver change
220                        // and a peer dependency is dependent on an older version as a public dep.
221                        || flattened_dep_public_graph.get(dep.name.as_str()).unwrap().iter().any(|(inner_dep_name, reqs)| {
222                            let inner_dep_pkg = check_run.get_package(inner_dep_name).unwrap();
223                            deps.contains_key(inner_dep_name) // if we are also dependant
224                                && package.is_dep_public(inner_dep_name) // its also a public dep
225                                && check_run.is_accepted_group(inner_dep_pkg.group()) // if the dep is part of the release group
226                                && inner_dep_pkg.next_version().is_some_and(|vers| reqs.iter().any(|dep_req| !dep_req.req.matches(&vers)))
227                        });
228
229                    if depends_on && !check_run.is_accepted_group(dep_pkg.group()) {
230                        anyhow::bail!(
231                            "could not update: `{}` because it depends on `{}` which is not part of the packages to be updated.",
232                            package.name,
233                            dep_pkg.name
234                        );
235                    }
236                }
237            }
238        }
239
240        let mut pr_body = String::from("## 🤖 New release\n\n");
241        let mut release_count = 0;
242        let workspace_manifest_path = metadata.workspace_root.join("Cargo.toml");
243
244        let mut workspace_manifest = if !self.changelogs_only {
245            let workspace_manifest = std::fs::read_to_string(&workspace_manifest_path).context("workspace manifest read")?;
246            Some((
247                workspace_manifest
248                    .parse::<toml_edit::DocumentMut>()
249                    .context("workspace manifest parse")?,
250                workspace_manifest,
251            ))
252        } else {
253            None
254        };
255
256        let mut workspace_metadata_packages = if let Some((workspace_manifest, _)) = &mut workspace_manifest {
257            let mut item = workspace_manifest.as_item_mut().as_table_like_mut().expect("table");
258            for key in ["workspace", "metadata", "xtask", "release", "packages"] {
259                item = item
260                    .entry(key)
261                    .or_insert(toml_edit::Item::Table({
262                        let mut table = Table::new();
263                        table.set_implicit(true);
264                        table
265                    }))
266                    .as_table_like_mut()
267                    .expect("table");
268            }
269
270            Some(item)
271        } else {
272            None
273        };
274
275        for package in check_run.all_packages() {
276            let _span = tracing::info_span!("update", package = %package.name).entered();
277            let version = package.next_version().or_else(|| {
278                if package.should_publish() && package.last_published_version().is_none() && !self.changelogs_only {
279                    Some(package.version.clone())
280                } else {
281                    None
282                }
283            });
284
285            release_count += 1;
286
287            let is_accepted_group = check_run.is_accepted_group(package.group());
288
289            let mut changelogs = if is_accepted_group {
290                if let Some(change_log_path_md) = package.changelog_path() {
291                    Some((
292                        change_log_path_md,
293                        generate_change_logs(&package.name, &mut change_fragments).context("generate")?,
294                    ))
295                } else {
296                    None
297                }
298            } else {
299                None
300            };
301
302            if !self.changelogs_only {
303                let cargo_toml_raw = std::fs::read_to_string(&package.manifest_path).context("read cargo toml")?;
304                let mut cargo_toml_edit = cargo_toml_raw.parse::<toml_edit::DocumentMut>().context("parse toml")?;
305
306                if let Some(version) = version.as_ref() {
307                    if is_accepted_group {
308                        pr_body.push_str(
309                            &fmtools::fmt(|mut f| {
310                                let mut f = indent_write::fmt::IndentWriter::new("  ", &mut f);
311                                write!(f, "* `{}`: ", package.name)?;
312                                let last_published = package.last_published_version();
313                                f.write_str(match &last_published {
314                                    None => " 📦 **New Crate**",
315                                    Some(v) if vers_to_comp(v.vers.clone()).matches(version) => " ✨ **Minor**",
316                                    Some(_) => " 🚀 **Major**",
317                                })?;
318
319                                let mut f = indent_write::fmt::IndentWriter::new("  ", f);
320                                match &last_published {
321                                    Some(base) => write!(f, "\n* Version: **`{}`** ➡️ **`{version}`**", base.vers)?,
322                                    None => write!(f, "\n* Version: **`{version}`**")?,
323                                }
324
325                                if package.group() != package.name.as_str() {
326                                    write!(f, " (group: **`{}`**)", package.group())?;
327                                }
328                                f.write_str("\n")?;
329                                Ok(())
330                            })
331                            .to_string(),
332                        );
333
334                        if let Some(workspace_metadata_packages) = &mut workspace_metadata_packages {
335                            workspace_metadata_packages.insert(package.name.as_str(), version.to_string().into());
336                        }
337
338                        cargo_toml_edit["package"]["version"] = version.to_string().into();
339                    }
340                }
341
342                tracing::debug!("checking deps");
343
344                for dep in &package.dependencies {
345                    if dep.path.is_none() {
346                        continue;
347                    }
348
349                    let kind = match dep.kind {
350                        DependencyKind::Build => "build-dependencies",
351                        DependencyKind::Normal => "dependencies",
352                        _ => continue,
353                    };
354
355                    let Some(pkg) = check_run.get_package(&dep.name) else {
356                        continue;
357                    };
358
359                    if !check_run.is_accepted_group(pkg.group()) {
360                        continue;
361                    }
362
363                    let depends_on = dep.req == pkg.unreleased_req();
364                    if !depends_on && pkg.next_version().is_none_or(|vers| dep.req.matches(&vers)) {
365                        tracing::debug!("skipping version update on {}", dep.name);
366                        continue;
367                    }
368
369                    let root = if let Some(target) = &dep.target {
370                        &mut cargo_toml_edit["target"][&target.to_string()]
371                    } else {
372                        cargo_toml_edit.as_item_mut()
373                    };
374
375                    let item = root[kind][&dep.name].as_table_like_mut().unwrap();
376                    let pkg_version = pkg.next_version().unwrap_or_else(|| pkg.version.clone());
377
378                    let version = if pkg.group() == package.group() {
379                        semver::VersionReq {
380                            comparators: vec![semver::Comparator {
381                                op: semver::Op::Exact,
382                                major: pkg_version.major,
383                                minor: Some(pkg_version.minor),
384                                patch: Some(pkg_version.patch),
385                                pre: pkg_version.pre.clone(),
386                            }],
387                        }
388                        .to_string()
389                    } else {
390                        if !depends_on {
391                            if let Some((_, changelogs)) = changelogs.as_mut() {
392                                let mut log =
393                                    PackageChangeLog::new("chore", format!("bump {} to `{pkg_version}`", dep.name));
394                                log.breaking = package.is_dep_public(&dep.name);
395                                changelogs.push(log)
396                            }
397                        }
398
399                        pkg_version.to_string()
400                    };
401
402                    item.insert("version", version.into());
403                }
404
405                let cargo_toml = cargo_toml_edit.to_string();
406                if cargo_toml != cargo_toml_raw {
407                    if !self.dry_run {
408                        tracing::debug!("updating {}", package.manifest_path);
409                        std::fs::write(&package.manifest_path, cargo_toml).context("write cargo toml")?;
410                    } else {
411                        tracing::warn!("not modifying {} because dry-run", package.manifest_path);
412                    }
413                }
414            }
415
416            if let Some((change_log_path_md, changelogs)) = changelogs {
417                update_change_log(
418                    &changelogs,
419                    &change_log_path_md,
420                    &package.name,
421                    version.as_ref(),
422                    package.last_published_version().map(|v| v.vers).as_ref(),
423                    self.dry_run,
424                )
425                .context("update")?;
426                if !self.dry_run {
427                    save_change_fragments(&mut change_fragments).context("save")?;
428                }
429                tracing::info!(package = %package.name, "updated change logs");
430            }
431        }
432
433        if let Some((workspace_manifest, workspace_manifest_str)) = workspace_manifest {
434            let workspace_manifest = workspace_manifest.to_string();
435            if workspace_manifest != workspace_manifest_str {
436                if self.dry_run {
437                    tracing::warn!("skipping write of {workspace_manifest_path}")
438                } else {
439                    std::fs::write(&workspace_manifest_path, workspace_manifest).context("write workspace metadata")?;
440                }
441            }
442        }
443
444        if release_count != 0 {
445            println!("{}", pr_body.trim());
446        } else {
447            tracing::info!("no packages to release!");
448        }
449
450        Ok(())
451    }
452}
453
454fn update_change_log(
455    logs: &[PackageChangeLog],
456    change_log_path_md: &Utf8Path,
457    name: &str,
458    version: Option<&Version>,
459    previous_version: Option<&Version>,
460    dry_run: bool,
461) -> anyhow::Result<()> {
462    let mut change_log = std::fs::read_to_string(change_log_path_md).context("failed to read CHANGELOG.md")?;
463
464    // Find the # [Unreleased] section
465    // So we can insert the new logs after it
466    let (mut breaking_changes, mut other_changes) = logs.iter().partition::<Vec<_>, _>(|log| log.breaking);
467    breaking_changes.sort_by_key(|log| &log.category);
468    other_changes.sort_by_key(|log| &log.category);
469
470    fn make_logs(logs: &[&PackageChangeLog]) -> String {
471        fmtools::fmt(|f| {
472            let mut first = true;
473            for log in logs {
474                if !first {
475                    f.write_char('\n')?;
476                }
477                first = false;
478
479                let (tag, desc) = log.description.split_once('\n').unwrap_or((&log.description, ""));
480                write!(f, "- {category}: {tag}", category = log.category, tag = tag.trim(),)?;
481
482                if !log.pr_numbers.is_empty() {
483                    f.write_str(" (")?;
484                    let mut first = true;
485                    for pr_number in &log.pr_numbers {
486                        if !first {
487                            f.write_str(", ")?;
488                        }
489                        first = false;
490                        write!(f, "[#{pr_number}](https://github.com/scufflecloud/scuffle/pull/{pr_number})")?;
491                    }
492                    f.write_str(")")?;
493                }
494
495                if !log.authors.is_empty() {
496                    f.write_str(" (")?;
497                    let mut first = true;
498                    let mut seen = HashSet::new();
499                    for author in &log.authors {
500                        let author = author.trim().trim_start_matches('@').trim();
501                        if !seen.insert(author.to_lowercase()) {
502                            continue;
503                        }
504
505                        if !first {
506                            f.write_str(", ")?;
507                        }
508                        first = false;
509                        f.write_char('@')?;
510                        f.write_str(author)?;
511                    }
512                    f.write_char(')')?;
513                }
514
515                let desc = desc.trim();
516
517                if !desc.is_empty() {
518                    f.write_str("\n\n")?;
519                    f.write_str(desc)?;
520                    f.write_char('\n')?;
521                }
522            }
523
524            Ok(())
525        })
526        .to_string()
527    }
528
529    let breaking_changes = make_logs(&breaking_changes);
530    let other_changes = make_logs(&other_changes);
531
532    let mut replaced = String::new();
533
534    replaced.push_str("## [Unreleased]\n");
535
536    if let Some(version) = version {
537        replaced.push_str(&format!(
538            "\n## [{version}](https://github.com/ScuffleCloud/scuffle/releases/tag/{name}-v{version}) - {date}\n\n",
539            date = chrono::Utc::now().date_naive().format("%Y-%m-%d")
540        ));
541
542        if let Some(previous_version) = &previous_version {
543            replaced.push_str(&format!(
544                "[View diff on diff.rs](https://diff.rs/{name}/{previous_version}/{name}/{version}/Cargo.toml)\n",
545            ));
546        }
547    }
548
549    if !breaking_changes.is_empty() {
550        replaced.push_str("\n### ⚠️ Breaking changes\n\n");
551        replaced.push_str(&breaking_changes);
552        replaced.push('\n');
553    }
554
555    if !other_changes.is_empty() {
556        replaced.push_str("\n### 🛠️ Non-breaking changes\n\n");
557        replaced.push_str(&other_changes);
558        replaced.push('\n');
559    }
560
561    change_log = change_log.replace("## [Unreleased]", replaced.trim());
562
563    if !dry_run {
564        std::fs::write(change_log_path_md, change_log).context("failed to write CHANGELOG.md")?;
565    } else {
566        tracing::warn!("not modifying {change_log_path_md} because dry-run");
567    }
568
569    Ok(())
570}
571
572fn generate_change_logs(
573    package: &str,
574    change_fragments: &mut BTreeMap<u64, Fragment>,
575) -> anyhow::Result<Vec<PackageChangeLog>> {
576    let mut logs = Vec::new();
577    let mut seen_logs = HashMap::new();
578
579    for fragment in change_fragments.values_mut() {
580        for log in fragment.remove_package(package).context("parse")? {
581            let key = (log.category.clone(), log.description.clone());
582            match seen_logs.entry(key) {
583                std::collections::hash_map::Entry::Vacant(v) => {
584                    v.insert(logs.len());
585                    logs.push(log);
586                }
587                std::collections::hash_map::Entry::Occupied(o) => {
588                    let old_log = &mut logs[*o.get()];
589                    old_log.pr_numbers.extend(log.pr_numbers);
590                    old_log.authors.extend(log.authors);
591                    old_log.breaking |= log.breaking;
592                }
593            }
594        }
595    }
596
597    Ok(logs)
598}
599
600fn save_change_fragments(fragments: &mut BTreeMap<u64, Fragment>) -> anyhow::Result<()> {
601    fragments
602        .values_mut()
603        .filter(|fragment| fragment.changed())
604        .try_for_each(|fragment| fragment.save().context("save"))?;
605
606    fragments.retain(|_, fragment| !fragment.deleted());
607
608    Ok(())
609}
610
611#[derive(Debug, Clone)]
612pub struct Fragment {
613    path: Utf8PathBuf,
614    pr_number: u64,
615    toml: toml_edit::DocumentMut,
616    changed: bool,
617    deleted: bool,
618}
619
620#[derive(Debug, Clone, Deserialize, Serialize)]
621#[serde(deny_unknown_fields)]
622pub struct PackageChangeLog {
623    #[serde(skip, default)]
624    pub pr_numbers: BTreeSet<u64>,
625    #[serde(alias = "cat")]
626    pub category: String,
627    #[serde(alias = "desc")]
628    pub description: String,
629    #[serde(default, skip_serializing_if = "Vec::is_empty")]
630    #[serde(alias = "author")]
631    pub authors: Vec<String>,
632    #[serde(default, skip_serializing_if = "is_false")]
633    #[serde(alias = "break", alias = "major")]
634    pub breaking: bool,
635}
636
637fn is_false(input: &bool) -> bool {
638    !*input
639}
640
641impl PackageChangeLog {
642    pub fn new(category: impl std::fmt::Display, desc: impl std::fmt::Display) -> Self {
643        Self {
644            pr_numbers: BTreeSet::new(),
645            authors: Vec::new(),
646            breaking: false,
647            category: category.to_string(),
648            description: desc.to_string(),
649        }
650    }
651}
652
653impl Fragment {
654    pub fn new(pr_number: u64, root: &Utf8Path) -> anyhow::Result<Self> {
655        let path = root.join("changes.d").join(format!("pr-{pr_number}.toml"));
656        if path.exists() {
657            let content = std::fs::read_to_string(&path).context("read")?;
658            Ok(Fragment {
659                pr_number,
660                path: path.to_path_buf(),
661                toml: content
662                    .parse::<toml_edit::DocumentMut>()
663                    .context("change log is not valid toml")?,
664                changed: false,
665                deleted: false,
666            })
667        } else {
668            Ok(Fragment {
669                changed: false,
670                deleted: true,
671                path: path.to_path_buf(),
672                pr_number,
673                toml: DocumentMut::new(),
674            })
675        }
676    }
677
678    pub fn changed(&self) -> bool {
679        self.changed
680    }
681
682    pub fn deleted(&self) -> bool {
683        self.deleted
684    }
685
686    pub fn path(&self) -> &Utf8Path {
687        &self.path
688    }
689
690    pub fn has_package(&self, package: &str) -> bool {
691        self.toml.contains_key(package)
692    }
693
694    pub fn items(&self) -> anyhow::Result<BTreeMap<String, Vec<PackageChangeLog>>> {
695        self.toml
696            .iter()
697            .map(|(package, item)| package_to_logs(self.pr_number, item.clone()).map(|logs| (package.to_owned(), logs)))
698            .collect()
699    }
700
701    pub fn add_log(&mut self, package: &str, log: &PackageChangeLog) {
702        if !self.toml.contains_key(package) {
703            self.toml.insert(package, toml_edit::Item::ArrayOfTables(Default::default()));
704        }
705
706        self.changed = true;
707
708        self.toml[package]
709            .as_array_of_tables_mut()
710            .unwrap()
711            .push(toml_edit::ser::to_document(log).expect("invalid log").as_table().clone())
712    }
713
714    pub fn remove_package(&mut self, package: &str) -> anyhow::Result<Vec<PackageChangeLog>> {
715        let Some(items) = self.toml.remove(package) else {
716            return Ok(Vec::new());
717        };
718
719        self.changed = true;
720
721        package_to_logs(self.pr_number, items)
722    }
723
724    pub fn save(&mut self) -> anyhow::Result<()> {
725        if !self.changed {
726            return Ok(());
727        }
728
729        if self.toml.is_empty() {
730            if !self.deleted {
731                tracing::debug!(path = %self.path, "removing change fragment cause empty");
732                std::fs::remove_file(&self.path).context("remove")?;
733                self.deleted = true;
734            }
735        } else {
736            tracing::debug!(path = %self.path, "saving change fragment");
737            std::fs::write(&self.path, self.toml.to_string()).context("write")?;
738            self.deleted = false;
739        }
740
741        self.changed = false;
742
743        Ok(())
744    }
745}
746
747fn package_to_logs(pr_number: u64, items: toml_edit::Item) -> anyhow::Result<Vec<PackageChangeLog>> {
748    let value = items.into_value().expect("items must be a value").into_deserializer();
749    let mut logs = Vec::<PackageChangeLog>::deserialize(value).context("deserialize")?;
750
751    logs.iter_mut().for_each(|log| {
752        log.category = log.category.to_lowercase();
753        log.pr_numbers = BTreeSet::from_iter([pr_number]);
754    });
755
756    Ok(logs)
757}