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                    && is_accepted_group
308                {
309                    pr_body.push_str(
310                        &fmtools::fmt(|mut f| {
311                            let mut f = indent_write::fmt::IndentWriter::new("  ", &mut f);
312                            write!(f, "* `{}`: ", package.name)?;
313                            let last_published = package.last_published_version();
314                            f.write_str(match &last_published {
315                                None => " 📦 **New Crate**",
316                                Some(v) if vers_to_comp(v.vers.clone()).matches(version) => " ✨ **Minor**",
317                                Some(_) => " 🚀 **Major**",
318                            })?;
319
320                            let mut f = indent_write::fmt::IndentWriter::new("  ", f);
321                            match &last_published {
322                                Some(base) => write!(f, "\n* Version: **`{}`** ➡️ **`{version}`**", base.vers)?,
323                                None => write!(f, "\n* Version: **`{version}`**")?,
324                            }
325
326                            if package.group() != package.name.as_str() {
327                                write!(f, " (group: **`{}`**)", package.group())?;
328                            }
329                            f.write_str("\n")?;
330                            Ok(())
331                        })
332                        .to_string(),
333                    );
334
335                    if let Some(workspace_metadata_packages) = &mut workspace_metadata_packages {
336                        workspace_metadata_packages.insert(package.name.as_str(), version.to_string().into());
337                    }
338
339                    cargo_toml_edit["package"]["version"] = version.to_string().into();
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 && let Some((_, changelogs)) = changelogs.as_mut() {
391                            let mut log = PackageChangeLog::new("chore", format!("bump {} to `{pkg_version}`", dep.name));
392                            log.breaking = package.is_dep_public(&dep.name);
393                            changelogs.push(log)
394                        }
395
396                        pkg_version.to_string()
397                    };
398
399                    item.insert("version", version.into());
400                }
401
402                let cargo_toml = cargo_toml_edit.to_string();
403                if cargo_toml != cargo_toml_raw {
404                    if !self.dry_run {
405                        tracing::debug!("updating {}", package.manifest_path);
406                        std::fs::write(&package.manifest_path, cargo_toml).context("write cargo toml")?;
407                    } else {
408                        tracing::warn!("not modifying {} because dry-run", package.manifest_path);
409                    }
410                }
411            }
412
413            if let Some((change_log_path_md, changelogs)) = changelogs {
414                update_change_log(
415                    &changelogs,
416                    &change_log_path_md,
417                    &package.name,
418                    version.as_ref(),
419                    package.last_published_version().map(|v| v.vers).as_ref(),
420                    self.dry_run,
421                )
422                .context("update")?;
423                if !self.dry_run {
424                    save_change_fragments(&mut change_fragments).context("save")?;
425                }
426                tracing::info!(package = %package.name, "updated change logs");
427            }
428        }
429
430        if let Some((workspace_manifest, workspace_manifest_str)) = workspace_manifest {
431            let workspace_manifest = workspace_manifest.to_string();
432            if workspace_manifest != workspace_manifest_str {
433                if self.dry_run {
434                    tracing::warn!("skipping write of {workspace_manifest_path}")
435                } else {
436                    std::fs::write(&workspace_manifest_path, workspace_manifest).context("write workspace metadata")?;
437                }
438            }
439        }
440
441        if release_count != 0 {
442            println!("{}", pr_body.trim());
443        } else {
444            tracing::info!("no packages to release!");
445        }
446
447        Ok(())
448    }
449}
450
451fn update_change_log(
452    logs: &[PackageChangeLog],
453    change_log_path_md: &Utf8Path,
454    name: &str,
455    version: Option<&Version>,
456    previous_version: Option<&Version>,
457    dry_run: bool,
458) -> anyhow::Result<()> {
459    let mut change_log = std::fs::read_to_string(change_log_path_md).context("failed to read CHANGELOG.md")?;
460
461    // Find the # [Unreleased] section
462    // So we can insert the new logs after it
463    let (mut breaking_changes, mut other_changes) = logs.iter().partition::<Vec<_>, _>(|log| log.breaking);
464    breaking_changes.sort_by_key(|log| &log.category);
465    other_changes.sort_by_key(|log| &log.category);
466
467    fn make_logs(logs: &[&PackageChangeLog]) -> String {
468        fmtools::fmt(|f| {
469            let mut first = true;
470            for log in logs {
471                if !first {
472                    f.write_char('\n')?;
473                }
474                first = false;
475
476                let (tag, desc) = log.description.split_once('\n').unwrap_or((&log.description, ""));
477                write!(f, "- {category}: {tag}", category = log.category, tag = tag.trim(),)?;
478
479                if !log.pr_numbers.is_empty() {
480                    f.write_str(" (")?;
481                    let mut first = true;
482                    for pr_number in &log.pr_numbers {
483                        if !first {
484                            f.write_str(", ")?;
485                        }
486                        first = false;
487                        write!(f, "[#{pr_number}](https://github.com/scufflecloud/scuffle/pull/{pr_number})")?;
488                    }
489                    f.write_str(")")?;
490                }
491
492                if !log.authors.is_empty() {
493                    f.write_str(" (")?;
494                    let mut first = true;
495                    let mut seen = HashSet::new();
496                    for author in &log.authors {
497                        let author = author.trim().trim_start_matches('@').trim();
498                        if !seen.insert(author.to_lowercase()) {
499                            continue;
500                        }
501
502                        if !first {
503                            f.write_str(", ")?;
504                        }
505                        first = false;
506                        f.write_char('@')?;
507                        f.write_str(author)?;
508                    }
509                    f.write_char(')')?;
510                }
511
512                let desc = desc.trim();
513
514                if !desc.is_empty() {
515                    f.write_str("\n\n")?;
516                    f.write_str(desc)?;
517                    f.write_char('\n')?;
518                }
519            }
520
521            Ok(())
522        })
523        .to_string()
524    }
525
526    let breaking_changes = make_logs(&breaking_changes);
527    let other_changes = make_logs(&other_changes);
528
529    let mut replaced = String::new();
530
531    replaced.push_str("## [Unreleased]\n");
532
533    if let Some(version) = version {
534        replaced.push_str(&format!(
535            "\n## [{version}](https://github.com/ScuffleCloud/scuffle/releases/tag/{name}-v{version}) - {date}\n\n",
536            date = chrono::Utc::now().date_naive().format("%Y-%m-%d")
537        ));
538
539        if let Some(previous_version) = &previous_version {
540            replaced.push_str(&format!(
541                "[View diff on diff.rs](https://diff.rs/{name}/{previous_version}/{name}/{version}/Cargo.toml)\n",
542            ));
543        }
544    }
545
546    if !breaking_changes.is_empty() {
547        replaced.push_str("\n### ⚠️ Breaking changes\n\n");
548        replaced.push_str(&breaking_changes);
549        replaced.push('\n');
550    }
551
552    if !other_changes.is_empty() {
553        replaced.push_str("\n### 🛠️ Non-breaking changes\n\n");
554        replaced.push_str(&other_changes);
555        replaced.push('\n');
556    }
557
558    change_log = change_log.replace("## [Unreleased]", replaced.trim());
559
560    if !dry_run {
561        std::fs::write(change_log_path_md, change_log).context("failed to write CHANGELOG.md")?;
562    } else {
563        tracing::warn!("not modifying {change_log_path_md} because dry-run");
564    }
565
566    Ok(())
567}
568
569fn generate_change_logs(
570    package: &str,
571    change_fragments: &mut BTreeMap<u64, Fragment>,
572) -> anyhow::Result<Vec<PackageChangeLog>> {
573    let mut logs = Vec::new();
574    let mut seen_logs = HashMap::new();
575
576    for fragment in change_fragments.values_mut() {
577        for log in fragment.remove_package(package).context("parse")? {
578            let key = (log.category.clone(), log.description.clone());
579            match seen_logs.entry(key) {
580                std::collections::hash_map::Entry::Vacant(v) => {
581                    v.insert(logs.len());
582                    logs.push(log);
583                }
584                std::collections::hash_map::Entry::Occupied(o) => {
585                    let old_log = &mut logs[*o.get()];
586                    old_log.pr_numbers.extend(log.pr_numbers);
587                    old_log.authors.extend(log.authors);
588                    old_log.breaking |= log.breaking;
589                }
590            }
591        }
592    }
593
594    Ok(logs)
595}
596
597fn save_change_fragments(fragments: &mut BTreeMap<u64, Fragment>) -> anyhow::Result<()> {
598    fragments
599        .values_mut()
600        .filter(|fragment| fragment.changed())
601        .try_for_each(|fragment| fragment.save().context("save"))?;
602
603    fragments.retain(|_, fragment| !fragment.deleted());
604
605    Ok(())
606}
607
608#[derive(Debug, Clone)]
609pub struct Fragment {
610    path: Utf8PathBuf,
611    pr_number: u64,
612    toml: toml_edit::DocumentMut,
613    changed: bool,
614    deleted: bool,
615}
616
617#[derive(Debug, Clone, Deserialize, Serialize)]
618#[serde(deny_unknown_fields)]
619pub struct PackageChangeLog {
620    #[serde(skip, default)]
621    pub pr_numbers: BTreeSet<u64>,
622    #[serde(alias = "cat")]
623    pub category: String,
624    #[serde(alias = "desc")]
625    pub description: String,
626    #[serde(default, skip_serializing_if = "Vec::is_empty")]
627    #[serde(alias = "author")]
628    pub authors: Vec<String>,
629    #[serde(default, skip_serializing_if = "is_false")]
630    #[serde(alias = "break", alias = "major")]
631    pub breaking: bool,
632}
633
634fn is_false(input: &bool) -> bool {
635    !*input
636}
637
638impl PackageChangeLog {
639    pub fn new(category: impl std::fmt::Display, desc: impl std::fmt::Display) -> Self {
640        Self {
641            pr_numbers: BTreeSet::new(),
642            authors: Vec::new(),
643            breaking: false,
644            category: category.to_string(),
645            description: desc.to_string(),
646        }
647    }
648}
649
650impl Fragment {
651    pub fn new(pr_number: u64, root: &Utf8Path) -> anyhow::Result<Self> {
652        let path = root.join("changes.d").join(format!("pr-{pr_number}.toml"));
653        if path.exists() {
654            let content = std::fs::read_to_string(&path).context("read")?;
655            Ok(Fragment {
656                pr_number,
657                path: path.to_path_buf(),
658                toml: content
659                    .parse::<toml_edit::DocumentMut>()
660                    .context("change log is not valid toml")?,
661                changed: false,
662                deleted: false,
663            })
664        } else {
665            Ok(Fragment {
666                changed: false,
667                deleted: true,
668                path: path.to_path_buf(),
669                pr_number,
670                toml: DocumentMut::new(),
671            })
672        }
673    }
674
675    pub fn changed(&self) -> bool {
676        self.changed
677    }
678
679    pub fn deleted(&self) -> bool {
680        self.deleted
681    }
682
683    pub fn path(&self) -> &Utf8Path {
684        &self.path
685    }
686
687    pub fn has_package(&self, package: &str) -> bool {
688        self.toml.contains_key(package)
689    }
690
691    pub fn items(&self) -> anyhow::Result<BTreeMap<String, Vec<PackageChangeLog>>> {
692        self.toml
693            .iter()
694            .map(|(package, item)| package_to_logs(self.pr_number, item.clone()).map(|logs| (package.to_owned(), logs)))
695            .collect()
696    }
697
698    pub fn add_log(&mut self, package: &str, log: &PackageChangeLog) {
699        if !self.toml.contains_key(package) {
700            self.toml.insert(package, toml_edit::Item::ArrayOfTables(Default::default()));
701        }
702
703        self.changed = true;
704
705        self.toml[package]
706            .as_array_of_tables_mut()
707            .unwrap()
708            .push(toml_edit::ser::to_document(log).expect("invalid log").as_table().clone())
709    }
710
711    pub fn remove_package(&mut self, package: &str) -> anyhow::Result<Vec<PackageChangeLog>> {
712        let Some(items) = self.toml.remove(package) else {
713            return Ok(Vec::new());
714        };
715
716        self.changed = true;
717
718        package_to_logs(self.pr_number, items)
719    }
720
721    pub fn save(&mut self) -> anyhow::Result<()> {
722        if !self.changed {
723            return Ok(());
724        }
725
726        if self.toml.is_empty() {
727            if !self.deleted {
728                tracing::debug!(path = %self.path, "removing change fragment cause empty");
729                std::fs::remove_file(&self.path).context("remove")?;
730                self.deleted = true;
731            }
732        } else {
733            tracing::debug!(path = %self.path, "saving change fragment");
734            std::fs::write(&self.path, self.toml.to_string()).context("write")?;
735            self.deleted = false;
736        }
737
738        self.changed = false;
739
740        Ok(())
741    }
742}
743
744fn package_to_logs(pr_number: u64, items: toml_edit::Item) -> anyhow::Result<Vec<PackageChangeLog>> {
745    let value = items.into_value().expect("items must be a value").into_deserializer();
746    let mut logs = Vec::<PackageChangeLog>::deserialize(value).context("deserialize")?;
747
748    logs.iter_mut().for_each(|log| {
749        log.category = log.category.to_lowercase();
750        log.pr_numbers = BTreeSet::from_iter([pr_number]);
751    });
752
753    Ok(logs)
754}