summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJulian Andres Klode <jak@debian.org>2025-09-26 16:18:42 +0000
committerJulian Andres Klode <jak@debian.org>2025-09-26 16:18:42 +0000
commit0b6f688806098b906d0c34f101696d35a60cf778 (patch)
treeeb72d44c499404fd46c04119e7e5fb214f7833f2
parent3c9399e643a07074d47c9bceca88e8d43ff55d36 (diff)
parent66962bee233b617e4a9ce436f9b26875511b35e6 (diff)
Merge branch 'history-command' into 'main'
History Command and Parsing See merge request apt-team/apt!516
-rw-r--r--apt-pkg/history.cc190
-rw-r--r--apt-pkg/history.h80
-rw-r--r--apt-private/private-history.cc239
-rw-r--r--apt-private/private-history.h11
-rw-r--r--cmdline/apt.cc20
-rwxr-xr-xtest/integration/test-history30
-rw-r--r--test/libapt/history_parse_test.cc113
7 files changed, 675 insertions, 8 deletions
diff --git a/apt-pkg/history.cc b/apt-pkg/history.cc
new file mode 100644
index 000000000..4e838a016
--- /dev/null
+++ b/apt-pkg/history.cc
@@ -0,0 +1,190 @@
+// -*- mode: cpp; mode: fold -*-
+// Description /*{{{*/
+/* ######################################################################
+ Set of functions for parsing the history log
+ ##################################################################### */
+/*}}}*/
+// Include Files /*{{{*/
+
+#include <config.h>
+
+#include <apt-pkg/configuration.h>
+#include <apt-pkg/error.h>
+#include <apt-pkg/fileutl.h>
+#include <apt-pkg/history.h>
+#include <apt-pkg/strutl.h>
+#include <apt-pkg/tagfile.h>
+
+#include <algorithm>
+#include <cctype>
+
+#include <glob.h>
+#include <apti18n.h> // for coloring
+
+namespace APT::History
+{
+
+static Change ParsePackageEvent(const std::string &event)
+{
+ Change change{};
+ // Remove all spaces
+ std::string trimEvent = event;
+ trimEvent.erase(std::remove_if(trimEvent.begin(), trimEvent.end(),
+ [](unsigned char c)
+ { return std::isspace(c); }),
+ trimEvent.end());
+ auto openParen = trimEvent.find('(');
+ if (openParen == std::string::npos)
+ return change;
+
+ change.package = trimEvent.substr(0, openParen);
+
+ auto closeParen = trimEvent.find(')', openParen);
+ if (closeParen == std::string::npos)
+ return change;
+
+ std::string versionStr = trimEvent.substr(openParen + 1, closeParen - openParen - 1);
+ auto values = VectorizeString(versionStr, ',');
+ for (size_t i = 0; i < values.size(); ++i)
+ {
+ if (i == 0 && std::isdigit(values[0][0]))
+ change.currentVersion = std::move(values[i]);
+ else if (i == 1 && std::isdigit(values[1][0]))
+ change.candidateVersion = std::move(values[i]);
+ else if (values[i] == "automatic")
+ change.automatic = true;
+ else
+ _error->Warning(_("Unknown flag: %s"), values[i].c_str());
+ }
+ return change;
+}
+
+static std::vector<std::string> SplitPackagesInContent(const std::string &content)
+{
+ std::vector<std::string> result;
+ std::size_t start = 0;
+
+ if (content.length() == 0)
+ return result;
+
+ // Split at "), " but keep the parenthesis.
+ while (true)
+ {
+ std::size_t pos = content.find("), ", start);
+ if (pos == std::string::npos)
+ {
+ result.push_back(content.substr(start));
+ break;
+ }
+ result.push_back(content.substr(start, pos - start + 1));
+ // Increment by 3 since 3 == len("), ")
+ start = pos + 3;
+ }
+ return result;
+}
+
+std::string KindToString(const Kind &kind)
+{
+ switch (kind)
+ {
+ case Kind::Install:
+ return "Install";
+ case Kind::Reinstall:
+ return "Reinstall";
+ case Kind::Upgrade:
+ return "Upgrade";
+ case Kind::Downgrade:
+ return "Downgrade";
+ case Kind::Remove:
+ return "Remove";
+ case Kind::Purge:
+ return "Purge";
+ default:
+ return "Undefined";
+ }
+}
+
+Entry ParseSection(
+ const pkgTagSection &section)
+{
+ Entry entry{};
+ entry.startDate = section.Find("Start-Date");
+ entry.endDate = section.Find("End-Date");
+ entry.cmdLine = section.Find("Commandline");
+ entry.requestingUser = section.Find("Requested-By");
+ entry.comment = section.Find("Comment");
+ entry.error = section.Find("Error");
+
+ std::string content = "";
+ const Kind kinds[] =
+ {
+ Kind::Install,
+ Kind::Reinstall,
+ Kind::Downgrade,
+ Kind::Upgrade,
+ Kind::Remove,
+ Kind::Purge,
+ };
+ for (const auto &kind : kinds)
+ {
+ content = section.Find(KindToString(kind));
+ if (content.empty())
+ continue;
+
+ std::vector<std::string> package_events = SplitPackagesInContent(content);
+ std::vector<Change> changes = {};
+ for (auto event : package_events)
+ {
+ Change change = ParsePackageEvent(event);
+ change.kind = kind;
+ changes.push_back(change);
+ }
+ // Changed packages should be in order
+ std::sort(changes.begin(), changes.end(), [](const Change &a, const Change &b)
+ { return a.package < b.package; });
+ entry.changeMap[kind] = changes;
+ }
+
+ return entry;
+}
+
+bool ParseFile(FileFd &fd, HistoryBuffer &buf)
+{
+ pkgTagFile file(&fd, FileFd::ReadOnly);
+ pkgTagSection tmpSection;
+ while (file.Step(tmpSection))
+ buf.push_back(ParseSection(tmpSection));
+ return true;
+}
+
+bool ParseLogDir(HistoryBuffer &buf)
+{
+ std::string files = _config->FindFile("Dir::Log::History") + "*";
+ const char *pattern = files.data();
+ glob_t result;
+
+ int ret = glob(pattern, GLOB_TILDE, nullptr, &result);
+ if (ret != 0)
+ return _error->Error(_("Cannot find history files: %s"), files.c_str());
+
+ for (size_t i = 0; i < result.gl_pathc; ++i)
+ {
+ FileFd fd;
+ if (not fd.Open(result.gl_pathv[i], FileFd::ReadOnly, FileFd::Extension))
+ return _error->Error(_("Could not open file: %s"), result.gl_pathv[i]);
+ if (not ParseFile(fd, buf))
+ return _error->Error(_("Could not parse file: %s"), result.gl_pathv[i]);
+ if (not fd.Close())
+ return _error->Error(_("Could not close file: %s"), result.gl_pathv[i]);
+ }
+
+ // Sort entries by time
+ std::sort(buf.begin(), buf.end(),
+ [](const Entry &a, const Entry &b)
+ {
+ return a.startDate < b.startDate;
+ });
+
+ return true;
+}
+} // namespace APT::History
diff --git a/apt-pkg/history.h b/apt-pkg/history.h
new file mode 100644
index 000000000..08e7be388
--- /dev/null
+++ b/apt-pkg/history.h
@@ -0,0 +1,80 @@
+// -*- mode: cpp; mode: fold -*-
+// Description /*{{{*/
+/* ######################################################################
+
+ History - Parse history logs to structured data.
+
+ ##################################################################### */
+/*}}}*/
+#ifndef APTPKG_HISTORY_H
+#define APTPKG_HISTORY_H
+
+#include <apt-pkg/fileutl.h>
+#include <apt-pkg/macros.h>
+#include <apt-pkg/tagfile.h>
+
+#include <map>
+#include <string>
+#include <vector>
+
+namespace APT::History
+{
+
+enum class Kind
+{
+ Install,
+ Reinstall,
+ Upgrade,
+ Downgrade,
+ Remove,
+ Purge,
+};
+
+// KindToString - Take a kind and return its
+// string representation.
+//
+// NOTE: Non-localized English version
+std::string KindToString(const Kind &kind);
+
+struct Change
+{
+ Kind kind;
+ std::string package;
+ std::string currentVersion;
+ std::string candidateVersion;
+ bool automatic = false;
+
+ private:
+ void *d; // pointer for future extension;
+};
+
+struct Entry
+{
+ // Strings instead of string_view to avoid reference errors
+ std::string startDate;
+ std::string endDate;
+ std::string cmdLine;
+ std::string comment;
+ std::string error;
+ std::string requestingUser;
+ std::map<Kind, std::vector<Change>> changeMap;
+
+ private:
+ void *d;
+};
+
+// History is defined as the collection of entries in the history log(s).
+typedef std::vector<Entry> HistoryBuffer;
+
+// ParseSection - Take a tag section and parse it as a
+// history log entry.
+APT_PUBLIC Entry ParseSection(const pkgTagSection &section);
+// ParseFile - Take a file descriptor and parse it as a history
+// log to the given buffer.
+// NOTE: Caller is responsible for closing the file descriptor.
+APT_PUBLIC bool ParseFile(FileFd &fd, HistoryBuffer &buf);
+// ParseLogDir - Parse the apt history log directory to the buffer.
+APT_PUBLIC bool ParseLogDir(HistoryBuffer &buf);
+} // namespace APT::History
+
+#endif
diff --git a/apt-private/private-history.cc b/apt-private/private-history.cc
new file mode 100644
index 000000000..834ff3411
--- /dev/null
+++ b/apt-private/private-history.cc
@@ -0,0 +1,239 @@
+// Include files
+#include <config.h>
+
+#include <apt-pkg/cmndline.h>
+#include <apt-pkg/configuration.h>
+#include <apt-pkg/error.h>
+#include <apt-pkg/fileutl.h>
+#include <apt-pkg/history.h>
+#include <apt-pkg/tagfile.h>
+
+#include <apt-private/private-history.h>
+
+#include <iomanip>
+#include <iostream>
+#include <stdexcept>
+#include <string>
+
+#include <glob.h>
+#include <apti18n.h> // for coloring
+ /*}}}*/
+
+using namespace APT::History;
+
+// ShortenCommand - Take a command and shorten it such that it adheres
+// to the given maximum length.
+static std::string ShortenCommand(const std::string &cmd, const std::size_t maxLen)
+{
+ std::string shortenedCmd = cmd;
+ if (shortenedCmd.starts_with("apt "))
+ shortenedCmd = shortenedCmd.substr(4);
+ if (shortenedCmd.length() > maxLen - 3)
+ return shortenedCmd.substr(0, maxLen - 4) + "...";
+ return shortenedCmd;
+}
+
+static std::string LocalizeKindToString(const Kind &kind)
+{
+ switch (kind)
+ {
+ case Kind::Install:
+ return _("Install");
+ case Kind::Reinstall:
+ return _("Reinstall");
+ case Kind::Upgrade:
+ return _("Upgrade");
+ case Kind::Downgrade:
+ return _("Downgrade");
+ case Kind::Remove:
+ return _("Remove");
+ case Kind::Purge:
+ return _("Purge");
+ default:
+ return _("Undefined");
+ }
+}
+
+// GetKindString - Take a history entry and construct the string
+// corresponding to the actions performed. Multpile actions are
+// alphabetically grouped such as:
+// Install, Remove, and Reinstall -> I,R,rI
+//
+// Shorthand legend:
+// Install -> I
+// Reinstall -> rI
+// Upgrade -> U
+// Downgrade -> D
+// Remove -> R
+// Purge -> P
+//
+static std::string GetKindString(const Entry &entry)
+{
+ // We want full output if there is only one action
+ if (entry.changeMap.size() == 1)
+ return LocalizeKindToString(entry.changeMap.begin()->first).data();
+
+ std::string kindGroup = "";
+ // add localization later
+ for (const auto &[action, _] : entry.changeMap)
+ {
+ switch (action)
+ {
+ case Kind::Install:
+ kindGroup += "I";
+ break;
+ case Kind::Reinstall:
+ kindGroup += "rI";
+ break;
+ case Kind::Upgrade:
+ kindGroup += "U";
+ break;
+ case Kind::Downgrade:
+ kindGroup += "D";
+ break;
+ case Kind::Remove:
+ kindGroup += "R";
+ break;
+ case Kind::Purge:
+ kindGroup += "P";
+ break;
+ default:
+ kindGroup += "INVALID";
+ }
+ kindGroup += ",";
+ }
+ // remove trailing ","
+ kindGroup.pop_back();
+ return kindGroup;
+}
+
+// PrintHistory - Take a history vector and print it.
+static void PrintHistoryVector(const HistoryBuffer buf, int columnWidth)
+{
+ // Calculate the width of the ID column, esentially the number of digits
+ int id = 0;
+ size_t sizeFrac = buf.size();
+ int idWidth = 2;
+ while (sizeFrac)
+ {
+ sizeFrac /= 10;
+ idWidth++;
+ }
+
+ // Print headers
+ auto writeEntry = [](std::string entry, size_t width)
+ {
+ std::cout << std::left << std::setw(width) << entry;
+ };
+ writeEntry(_("ID"), idWidth);
+ writeEntry(_("Command line"), columnWidth);
+ // NOTE: if date format is different,
+ // this width needs to change
+ writeEntry(_("Date and Time"), 23); // width for datestring
+ writeEntry(_("Action"), 10); // 9 chars longest action type
+ writeEntry(_("Changes"), columnWidth);
+ std::cout << "\n\n";
+
+ // Each entry corresponds to a row
+ for (auto entry : buf)
+ {
+ writeEntry(std::to_string(id), idWidth);
+ writeEntry(ShortenCommand(entry.cmdLine, columnWidth), columnWidth);
+ writeEntry(entry.startDate, 23);
+ // If there are multiple actions, we want to group them
+ writeEntry(GetKindString(entry), 10);
+
+ // Count the number of packages changed
+ size_t numChanges = 0;
+ for (const auto &[_, changes] : entry.changeMap)
+ numChanges += changes.size();
+ writeEntry(std::to_string(numChanges), columnWidth);
+ std::cout << "\n";
+ id++;
+ }
+}
+
+// PrintChange - Take a change and print the event for that package.
+// Example:
+// "package (0.1.0, 0.2.0)" and "Upgrade" -> "Upgrade package (0.1.0 -> 0.2.0)"
+// "package (0.1.0)" and "Install" -> "Install package (0.1.0)"
+static void PrintChange(const Change &change)
+{
+ std::cout << " " << LocalizeKindToString(change.kind) << " " << change.package;
+ std::cout << " (" << change.currentVersion;
+ if (not change.candidateVersion.empty())
+ std::cout << " -> " << change.candidateVersion;
+ if (change.automatic)
+ std::cout << ", " << _("automatic");
+
+ std::cout << ")";
+}
+
+// PrintDetailedEntry - Take a history buffer and print the detailed
+// transaction details for the given transaction id.
+static void PrintDetailedEntry(const HistoryBuffer &buf, const size_t id)
+{
+ Entry entry = buf[id];
+ std::cout << _("Transaction ID") << ": " << id << "\n";
+ std::cout << _("Start time") << ": " << entry.startDate << "\n";
+ std::cout << _("End time") << ": " << entry.endDate << "\n";
+ std::cout << _("Requested by") << ": " << entry.requestingUser << "\n";
+ std::cout << _("Command line") << ": " << entry.cmdLine << "\n";
+ if (not entry.error.empty())
+ {
+ std::cout << _config->Find("APT::Color::Red") << _("Error") << ": ";
+ std::cout << entry.error << _config->Find("APT::Color::Neutral") << "\n";
+ }
+ if (not entry.comment.empty())
+ std::cout << _("Comment") << ": " << entry.comment << "\n";
+
+ // For each performed action, print what it did to each package
+ std::cout << _("Packages changed") << ":" << "\n";
+ for (const auto &[_, changes] : entry.changeMap)
+ {
+ for (const auto &change : changes)
+ {
+ PrintChange(change);
+ std::cout << "\n";
+ }
+ std::cout << "\n";
+ }
+}
+
+bool DoHistoryList(CommandLine &Cmd)
+{
+ HistoryBuffer buf = {};
+
+ if (not ParseLogDir(buf))
+ return _error->Error(_("Could not read: %s"),
+ _config->FindFile("Dir::Log::History").data());
+ PrintHistoryVector(buf, 25);
+
+ return true;
+}
+
+bool DoHistoryInfo(CommandLine &Cmd)
+{
+ HistoryBuffer buf = {};
+ if (not ParseLogDir(buf))
+ return _error->Error(_("Could not read: %s"),
+ _config->FindFile("Dir::Log::History").data());
+
+ size_t id = 0;
+ for (size_t i = 1; i < Cmd.FileSize(); i++)
+ {
+ try
+ {
+ id = std::stoi(*(Cmd.FileList + i));
+ }
+ catch (const std::invalid_argument &e)
+ {
+ return _error->Error(_("'%s' not a valid ID."), *(Cmd.FileList + i));
+ }
+ if (buf.size() <= id)
+ return _error->Error(_("Transaction ID '%ld' out of bounds."), id);
+ PrintDetailedEntry(buf, id);
+ }
+
+ return true;
+}
diff --git a/apt-private/private-history.h b/apt-private/private-history.h
new file mode 100644
index 000000000..dfe0340f1
--- /dev/null
+++ b/apt-private/private-history.h
@@ -0,0 +1,11 @@
+#ifndef APT_PRIVATE_HISTORY_H
+#define APT_PRIVATE_HISTORY_H
+
+#include <apt-pkg/cmndline.h>
+#include <apt-pkg/history.h>
+#include <apt-pkg/macros.h>
+#include <apt-pkg/tagfile.h>
+
+APT_PUBLIC bool DoHistoryList(CommandLine &Cmd);
+APT_PUBLIC bool DoHistoryInfo(CommandLine &Cmd);
+#endif
diff --git a/cmdline/apt.cc b/cmdline/apt.cc
index a876085ce..545b6d0b4 100644
--- a/cmdline/apt.cc
+++ b/cmdline/apt.cc
@@ -22,6 +22,7 @@
#include <apt-private/private-cmndline.h>
#include <apt-private/private-depends.h>
#include <apt-private/private-download.h>
+#include <apt-private/private-history.h>
#include <apt-private/private-install.h>
#include <apt-private/private-list.h>
#include <apt-private/private-main.h>
@@ -83,7 +84,7 @@ static std::vector<aptDispatchWithHelp> GetCommands() /*{{{*/
{"remove", &DoInstall, _("remove packages")},
{"autoremove", &DoInstall, _("automatically remove all unused packages")},
{"auto-remove", &DoInstall, nullptr},
- {"autopurge",&DoInstall, nullptr},
+ {"autopurge", &DoInstall, nullptr},
{"purge", &DoInstall, nullptr},
// system wide stuff
@@ -91,6 +92,10 @@ static std::vector<aptDispatchWithHelp> GetCommands() /*{{{*/
{"upgrade", &DoUpgrade, _("upgrade the system by installing/upgrading packages")},
{"full-upgrade", &DoDistUpgrade, _("upgrade the system by removing/installing/upgrading packages")},
+ // history stuff
+ {"history-list", &DoHistoryList, _("show list of history")},
+ {"history-info", &DoHistoryInfo, _("show info on specific transactions")},
+
// misc
{"edit-sources", &EditSources, _("edit the source information file")},
{"modernize-sources", &ModernizeSources, _("modernize .list files to .sources files")},
@@ -101,11 +106,11 @@ static std::vector<aptDispatchWithHelp> GetCommands() /*{{{*/
// for compat with muscle memory
{"dist-upgrade", &DoDistUpgrade, nullptr},
- {"showsrc",&ShowSrcPackage, nullptr},
- {"depends",&Depends, nullptr},
- {"rdepends",&RDepends, nullptr},
- {"policy",&Policy, nullptr},
- {"build-dep", &DoBuildDep,nullptr},
+ {"showsrc", &ShowSrcPackage, nullptr},
+ {"depends", &Depends, nullptr},
+ {"rdepends", &RDepends, nullptr},
+ {"policy", &Policy, nullptr},
+ {"build-dep", &DoBuildDep, nullptr},
{"clean", &DoClean, nullptr},
{"distclean", &DoDistClean, nullptr},
{"dist-clean", &DoDistClean, nullptr},
@@ -116,8 +121,7 @@ static std::vector<aptDispatchWithHelp> GetCommands() /*{{{*/
{"changelog", &DoChangelog, nullptr},
{"info", &ShowPackage, nullptr},
- {nullptr, nullptr, nullptr}
- };
+ {nullptr, nullptr, nullptr}};
}
/*}}}*/
int main(int argc, const char *argv[]) /*{{{*/
diff --git a/test/integration/test-history b/test/integration/test-history
index 47829e162..34489fce6 100755
--- a/test/integration/test-history
+++ b/test/integration/test-history
@@ -38,3 +38,33 @@ Commandline: dummy
Comment: A test comment
Install: pkg2:amd64 (1)
End-Date: dummy" cathistory
+
+normalizedinfo() {
+ apt history-info 0 | sed -E \
+ -e 's/^( *-?Start time *: ).*/\1dummy/' \
+ -e 's/^( *-?End time *: ).*/\1dummy/' \
+ -e 's|^( *-?Command line *: ).*|\1dummy|'
+}
+
+testsuccessequal "Transaction ID: 0
+Start time: dummy
+End time: dummy
+Requested by:
+Command line: dummy
+Packages changed:
+ Install pkg1:amd64 (1)
+" normalizedinfo
+
+# replace aribtrary command line and date with "dummy".
+normalizedlist() {
+ apt history-list | sed -E 's/^([0-9]+)[[:space:]]+'\
+'[^[:space:]]+[[:space:]]+'\
+'[0-9]{4}-[0-9]{2}-[0-9]{2}[[:space:]]+'\
+'[0-9]{2}:[0-9]{2}:[0-9]{2}/'\
+'\1 dummy dummy /'
+}
+
+testsuccessequal "ID Command line Date and Time Action Changes
+
+0 dummy dummy Install 1
+1 dummy dummy Install 1 " normalizedlist
diff --git a/test/libapt/history_parse_test.cc b/test/libapt/history_parse_test.cc
new file mode 100644
index 000000000..526e1573f
--- /dev/null
+++ b/test/libapt/history_parse_test.cc
@@ -0,0 +1,113 @@
+#include <config.h>
+
+#include <apt-pkg/configuration.h>
+#include <apt-pkg/fileutl.h>
+#include <apt-pkg/history.h>
+#include <apt-pkg/indexcopy.h>
+#include <apt-pkg/tagfile.h>
+
+#include "common.h"
+
+#include "file-helpers.h"
+
+using namespace APT::History;
+
+TEST(HistoryParseTest, SectionToEntry)
+{
+ FileFd fd;
+ const char *entry_section =
+ "Start-Date: 2025-09-01 15:22:56\n"
+ "Commandline: apt install rust-coreutils\n"
+ "Requested-By: user (1000)\n"
+ "Error: An error occurred\n"
+ "Comment: This is a comment\n"
+ "Install: rust-coreutils:amd64 (0.1.0+git20250813.4af2a84-0ubuntu2)\n"
+ "End-Date: 2025-09-01 15:22:57";
+
+ openTemporaryFile("packagesection", fd, entry_section);
+
+ pkgTagFile tfile(&fd);
+ pkgTagSection section;
+ ASSERT_TRUE(tfile.Step(section));
+
+ Entry entry = ParseSection(section);
+ EXPECT_EQ("2025-09-01 15:22:56", entry.startDate);
+ EXPECT_EQ("2025-09-01 15:22:57", entry.endDate);
+ EXPECT_EQ("apt install rust-coreutils", entry.cmdLine);
+ EXPECT_EQ("user (1000)", entry.requestingUser);
+ EXPECT_EQ("An error occurred", entry.error);
+ EXPECT_EQ("This is a comment", entry.comment);
+ EXPECT_EQ("rust-coreutils:amd64", entry.changeMap[Kind::Install][0].package);
+ EXPECT_EQ("0.1.0+git20250813.4af2a84-0ubuntu2", entry.changeMap[Kind::Install][0].currentVersion);
+}
+
+TEST(HistoryParseTest, EmptyOptionalFields)
+{
+ FileFd fd;
+ const char *entry_section =
+ "Start-Date: 2025-09-01 15:22:56\n"
+ "Commandline: apt install rust-coreutils\n"
+ "Requested-By: user (1000)\n"
+ "Install: rust-coreutils:amd64 (0.1.0+git20250813.4af2a84-0ubuntu2)\n"
+ "End-Date: 2025-09-01 15:22:57";
+ openTemporaryFile("packagesection", fd, entry_section);
+ pkgTagFile tfile(&fd);
+ pkgTagSection section;
+ ASSERT_TRUE(tfile.Step(section));
+
+ Entry entry = ParseSection(section);
+ EXPECT_EQ("", entry.error);
+ EXPECT_EQ("", entry.comment);
+}
+
+TEST(HistoryParseTest, MultipleActions)
+{
+ FileFd fd;
+ const char *entry_section =
+ "Start-Date: 2025-09-01 15:22:56\n"
+ "Commandline: apt install rust-coreutils\n"
+ "Requested-By: user (1000)\n"
+ "Error: An error occurred\n"
+ "Comment: This is a comment\n"
+ "Install: rust-coreutils:amd64 (0.1.0+git20250813.4af2a84-0ubuntu2)\n"
+ "Remove: rust-coreutils:amd64 (0.1.0+git20250813.4af2a84-0ubuntu2)\n"
+ "Downgrade: rust-coreutils:amd64 (0.1.0, 0.0.0)\n"
+ "Reinstall: rust-coreutils:amd64 (0.1.0+git20250813.4af2a84-0ubuntu2)\n"
+ "Upgrade: rust-coreutils:amd64 (0.1.0, 0.2.0)\n"
+ "Purge: rust-coreutils:amd64 (0.1.0+git20250813.4af2a84-0ubuntu2)\n"
+ "End-Date: 2025-09-01 15:22:57";
+
+ openTemporaryFile("packagesection", fd, entry_section);
+
+ pkgTagFile tfile(&fd);
+ pkgTagSection section;
+ ASSERT_TRUE(tfile.Step(section));
+
+ Entry entry = ParseSection(section);
+ Change installChange = entry.changeMap[Kind::Install][0];
+ Change removeChange = entry.changeMap[Kind::Remove][0];
+ Change downgradeChange = entry.changeMap[Kind::Downgrade][0];
+ Change reinstallChange = entry.changeMap[Kind::Reinstall][0];
+ Change upgradeChange = entry.changeMap[Kind::Upgrade][0];
+ Change purgeChange = entry.changeMap[Kind::Purge][0];
+
+ EXPECT_EQ("rust-coreutils:amd64", installChange.package);
+ EXPECT_EQ("0.1.0+git20250813.4af2a84-0ubuntu2", installChange.currentVersion);
+
+ EXPECT_EQ("rust-coreutils:amd64", removeChange.package);
+ EXPECT_EQ("0.1.0+git20250813.4af2a84-0ubuntu2", removeChange.currentVersion);
+
+ EXPECT_EQ("rust-coreutils:amd64", downgradeChange.package);
+ EXPECT_EQ("0.1.0", downgradeChange.currentVersion);
+ EXPECT_EQ("0.0.0", downgradeChange.candidateVersion);
+
+ EXPECT_EQ("rust-coreutils:amd64", reinstallChange.package);
+ EXPECT_EQ("0.1.0+git20250813.4af2a84-0ubuntu2", reinstallChange.currentVersion);
+
+ EXPECT_EQ("rust-coreutils:amd64", upgradeChange.package);
+ EXPECT_EQ("0.1.0", upgradeChange.currentVersion);
+ EXPECT_EQ("0.2.0", upgradeChange.candidateVersion);
+
+ EXPECT_EQ("rust-coreutils:amd64", purgeChange.package);
+ EXPECT_EQ("0.1.0+git20250813.4af2a84-0ubuntu2", purgeChange.currentVersion);
+}