Compare commits
2
Commits
a0e74073bf
...
9238aa961f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9238aa961f | ||
|
|
d1056fbbbf |
@@ -34,6 +34,7 @@ ssh2 = "0.9.5"
|
|||||||
gpgme = "0.11"
|
gpgme = "0.11"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
lazy_static = "1.4.0"
|
lazy_static = "1.4.0"
|
||||||
|
unicode-width = "0.2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
test-log = "0.2.19"
|
test-log = "0.2.19"
|
||||||
|
|||||||
+275
-26
@@ -77,7 +77,10 @@ pub async fn generate_entry(
|
|||||||
std::env::current_dir()?
|
std::env::current_dir()?
|
||||||
};
|
};
|
||||||
let commits = match Repository::open(&repo_path) {
|
let commits = match Repository::open(&repo_path) {
|
||||||
Ok(repo) => get_commits_since_version(&repo, &old_version)?,
|
Ok(repo) => {
|
||||||
|
let repo_changelog = changelog_in_repo(&repo, &changelog_path);
|
||||||
|
get_commits_since_version(&repo, &old_version, repo_changelog.as_deref())?
|
||||||
|
}
|
||||||
// If there is no git repo (e.g. package downloaded from archive),
|
// If there is no git repo (e.g. package downloaded from archive),
|
||||||
// we just generate an empty list of changes
|
// we just generate an empty list of changes
|
||||||
Err(_e) => Vec::new(),
|
Err(_e) => Vec::new(),
|
||||||
@@ -362,51 +365,75 @@ pub fn parse_changelog_footer(path: &Path) -> Result<(String, String), Box<dyn s
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Obtain all commit messages as a list since a tagged version in a git repository
|
* Obtain all commit messages as a list since the previous release, to
|
||||||
|
* format into a changelog entry
|
||||||
*/
|
*/
|
||||||
fn get_commits_since_version(
|
fn get_commits_since_version(
|
||||||
repo: &Repository,
|
repo: &Repository,
|
||||||
version: &str,
|
version: &str,
|
||||||
|
changelog_path: Option<&Path>,
|
||||||
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||||
let mut revwalk = repo.revwalk()?;
|
// Preferred boundary: the tag of the current (previous) version
|
||||||
revwalk.set_sorting(Sort::TIME)?;
|
if let Some(tag_id) = find_version_tag(repo, version) {
|
||||||
|
return commit_summaries_since(repo, tag_id);
|
||||||
|
}
|
||||||
|
|
||||||
// We are looking for debian version numbers
|
// No tag: fall back to the last commit that modified the changelog
|
||||||
// Depending on the repo, they could be tagged with the following formats
|
// itself, whose commit introduces the previous entry. Its changes
|
||||||
let tag_names = vec![
|
// since then are the new entry's content — so entries stay correct in
|
||||||
|
// repositories that commit their changelogs without tagging them.
|
||||||
|
let Some(rel_path) = changelog_path else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
let Some(boundary) = last_commit_touching(repo, rel_path)? else {
|
||||||
|
// The changelog was never committed; nothing to walk from
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prefer a tag of the version the committed changelog carries: it may
|
||||||
|
// point past the changelog commit itself (e.g. a later release commit)
|
||||||
|
let boundary_id = version_at_commit(repo, &boundary, rel_path)
|
||||||
|
.and_then(|committed_version| find_version_tag(repo, &committed_version))
|
||||||
|
.unwrap_or_else(|| boundary.id());
|
||||||
|
commit_summaries_since(repo, boundary_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The commit names a Debian version may be tagged under in a repository
|
||||||
|
fn version_tag_names(version: &str) -> Vec<String> {
|
||||||
|
vec![
|
||||||
format!("debian/{}", version),
|
format!("debian/{}", version),
|
||||||
format!("debian/v{}", version),
|
format!("debian/v{}", version),
|
||||||
format!("v{}", version),
|
format!("v{}", version),
|
||||||
version.to_string(),
|
version.to_string(),
|
||||||
];
|
]
|
||||||
|
}
|
||||||
|
|
||||||
// Look for the different tag formats in the git repository
|
/// Find the commit a version tag points at, probing the usual tag formats
|
||||||
let mut tag_id: Option<Oid> = None;
|
fn find_version_tag(repo: &Repository, version: &str) -> Option<Oid> {
|
||||||
for tag_name in tag_names {
|
for tag_name in version_tag_names(version) {
|
||||||
if let Ok(r) = repo.revparse_single(&tag_name) {
|
if let Ok(r) = repo.revparse_single(&tag_name) {
|
||||||
// If we found either a commit or a tag matching the name,
|
// A matching name resolves either to a commit (lightweight
|
||||||
// we have found our version
|
// tag) or to a tag object (annotated tag)
|
||||||
if let Some(commit) = r.as_commit() {
|
if let Some(commit) = r.as_commit() {
|
||||||
tag_id = Some(commit.id());
|
return Some(commit.id());
|
||||||
break;
|
|
||||||
} else if let Some(tag) = r.as_tag() {
|
} else if let Some(tag) = r.as_tag() {
|
||||||
tag_id = Some(tag.target_id());
|
return Some(tag.target_id());
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(tid) = tag_id {
|
/// The commit summaries from HEAD down to (excluding) `boundary`
|
||||||
|
fn commit_summaries_since(
|
||||||
|
repo: &Repository,
|
||||||
|
boundary: Oid,
|
||||||
|
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||||
|
let mut revwalk = repo.revwalk()?;
|
||||||
|
revwalk.set_sorting(Sort::TIME)?;
|
||||||
revwalk.push_head()?;
|
revwalk.push_head()?;
|
||||||
revwalk.hide(tid)?;
|
revwalk.hide(boundary)?;
|
||||||
} else {
|
|
||||||
// Tag not found...
|
|
||||||
// We return an empty list.
|
|
||||||
// TODO: Can we do better?
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add all commit messages from that tagged version to head in the list
|
|
||||||
let mut commits = Vec::new();
|
let mut commits = Vec::new();
|
||||||
for id in revwalk {
|
for id in revwalk {
|
||||||
let id = id?;
|
let id = id?;
|
||||||
@@ -421,6 +448,81 @@ fn get_commits_since_version(
|
|||||||
Ok(commits)
|
Ok(commits)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The changelog path relative to the repository root, used to walk the
|
||||||
|
/// history of the file. `None` when the changelog lives outside the
|
||||||
|
/// repository working directory.
|
||||||
|
fn changelog_in_repo(repo: &Repository, changelog_path: &Path) -> Option<std::path::PathBuf> {
|
||||||
|
let workdir = repo.workdir()?.canonicalize().ok()?;
|
||||||
|
let absolute = if changelog_path.is_absolute() {
|
||||||
|
changelog_path.to_path_buf()
|
||||||
|
} else {
|
||||||
|
std::env::current_dir().ok()?.join(changelog_path)
|
||||||
|
};
|
||||||
|
let absolute = absolute.canonicalize().ok()?;
|
||||||
|
absolute
|
||||||
|
.strip_prefix(&workdir)
|
||||||
|
.ok()
|
||||||
|
.map(|p| p.to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last commit that modified the file at `rel_path`, newest first
|
||||||
|
fn last_commit_touching<'a>(
|
||||||
|
repo: &'a Repository,
|
||||||
|
rel_path: &Path,
|
||||||
|
) -> Result<Option<git2::Commit<'a>>, Box<dyn std::error::Error>> {
|
||||||
|
let mut revwalk = repo.revwalk()?;
|
||||||
|
revwalk.set_sorting(Sort::TIME)?;
|
||||||
|
if repo.head().is_err() {
|
||||||
|
// Unborn HEAD: the repository has no commits yet
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
revwalk.push_head()?;
|
||||||
|
|
||||||
|
for id in revwalk {
|
||||||
|
let commit = repo.find_commit(id?)?;
|
||||||
|
if commit_touches_path(repo, &commit, rel_path)? {
|
||||||
|
return Ok(Some(commit));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `commit` modified the file at `rel_path` relative to its first
|
||||||
|
/// parent (a root commit counts when it adds the file)
|
||||||
|
fn commit_touches_path(
|
||||||
|
repo: &Repository,
|
||||||
|
commit: &git2::Commit<'_>,
|
||||||
|
rel_path: &Path,
|
||||||
|
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||||
|
let new_tree = commit.tree()?;
|
||||||
|
let parent_tree = if commit.parent_count() > 0 {
|
||||||
|
Some(commit.parent(0)?.tree()?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let mut opts = git2::DiffOptions::new();
|
||||||
|
opts.pathspec(rel_path.to_string_lossy().to_string());
|
||||||
|
let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&new_tree), Some(&mut opts))?;
|
||||||
|
Ok(diff.deltas().len() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The version recorded by the changelog content as of `commit`
|
||||||
|
fn version_at_commit(
|
||||||
|
repo: &Repository,
|
||||||
|
commit: &git2::Commit<'_>,
|
||||||
|
rel_path: &Path,
|
||||||
|
) -> Option<String> {
|
||||||
|
let blob = commit
|
||||||
|
.tree()
|
||||||
|
.ok()?
|
||||||
|
.get_path(rel_path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|entry| repo.find_blob(entry.id()).ok())?;
|
||||||
|
let content = std::str::from_utf8(blob.content()).ok()?;
|
||||||
|
let entry = crate::debian::parse_changelog_entry_from_str(content).ok()?;
|
||||||
|
Some(entry.version.full())
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Create a changelog entry from information, i.e. format that information
|
* Create a changelog entry from information, i.e. format that information
|
||||||
* into a changelog entry
|
* into a changelog entry
|
||||||
@@ -703,6 +805,153 @@ mod tests {
|
|||||||
assert!(content.contains("mypackage (0.1.0-1) unstable; urgency=medium"));
|
assert!(content.contains("mypackage (0.1.0-1) unstable; urgency=medium"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Without any version tag, the history of the changelog itself
|
||||||
|
/// delimits the entry: the commits since the changelog was last
|
||||||
|
/// committed are the changes, and the changelog commit is not one.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_generate_entry_without_tags() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let repo_dir = temp_dir.path();
|
||||||
|
setup_repo(repo_dir);
|
||||||
|
|
||||||
|
let changelog_path = repo_dir.join("debian/changelog");
|
||||||
|
std::fs::create_dir_all(repo_dir.join("debian")).unwrap();
|
||||||
|
let initial_content = "mypackage (0.1.0-1) unstable; urgency=medium\n\n * Initial release\n\n -- Maintainer <m@e.com> Wed, 01 Jan 2020 00:00:00 +0000\n";
|
||||||
|
std::fs::write(&changelog_path, initial_content).unwrap();
|
||||||
|
|
||||||
|
// Commit the changelog, but do not tag it
|
||||||
|
Command::new("git")
|
||||||
|
.arg("add")
|
||||||
|
.arg(".")
|
||||||
|
.current_dir(repo_dir)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
commit(repo_dir, "Initial commit");
|
||||||
|
|
||||||
|
commit(repo_dir, "Fix bug A");
|
||||||
|
commit(repo_dir, "Add feature B");
|
||||||
|
|
||||||
|
let _identity = IDENTITY_LOCK.lock().await;
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("DEBFULLNAME", "Maintainer Maintainer");
|
||||||
|
std::env::set_var("DEBEMAIL", "maintainer@maintainer.com");
|
||||||
|
}
|
||||||
|
generate_entry(
|
||||||
|
"debian/changelog",
|
||||||
|
Some(repo_dir),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
EntryKind::Normal,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("DEBFULLNAME");
|
||||||
|
std::env::remove_var("DEBEMAIL");
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = std::fs::read_to_string(&changelog_path).unwrap();
|
||||||
|
assert!(content.contains("mypackage (0.1.0-2) unstable; urgency=medium"));
|
||||||
|
assert!(content.contains("* Fix bug A"));
|
||||||
|
assert!(content.contains("* Add feature B"));
|
||||||
|
assert!(!content.contains("* Initial commit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A changelog that was never committed has no revision to walk the
|
||||||
|
/// history from: the entry is generated without commit summaries.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_generate_entry_uncommitted_changelog() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let repo_dir = temp_dir.path();
|
||||||
|
setup_repo(repo_dir);
|
||||||
|
|
||||||
|
let changelog_path = repo_dir.join("debian/changelog");
|
||||||
|
std::fs::create_dir_all(repo_dir.join("debian")).unwrap();
|
||||||
|
std::fs::write(&changelog_path, "mypackage (0.1.0-1) unstable; urgency=medium\n\n * Initial release\n\n -- Maintainer <m@e.com> Wed, 01 Jan 2020 00:00:00 +0000\n").unwrap();
|
||||||
|
commit(repo_dir, "Some change");
|
||||||
|
|
||||||
|
let _identity = IDENTITY_LOCK.lock().await;
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("DEBFULLNAME", "Maintainer Maintainer");
|
||||||
|
std::env::set_var("DEBEMAIL", "maintainer@maintainer.com");
|
||||||
|
}
|
||||||
|
generate_entry(
|
||||||
|
"debian/changelog",
|
||||||
|
Some(repo_dir),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
EntryKind::Normal,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("DEBFULLNAME");
|
||||||
|
std::env::remove_var("DEBEMAIL");
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = std::fs::read_to_string(&changelog_path).unwrap();
|
||||||
|
assert!(content.contains("mypackage (0.1.0-2) unstable; urgency=medium"));
|
||||||
|
assert!(!content.contains("* Some change"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An uncommitted newer entry on top of the changelog (e.g. UNRELEASED
|
||||||
|
/// from a previous run) does not hide the previous version: the tag of
|
||||||
|
/// the last *committed* version is still used as the boundary.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_generate_entry_tag_from_committed_version() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let repo_dir = temp_dir.path();
|
||||||
|
setup_repo(repo_dir);
|
||||||
|
|
||||||
|
let changelog_path = repo_dir.join("debian/changelog");
|
||||||
|
std::fs::create_dir_all(repo_dir.join("debian")).unwrap();
|
||||||
|
let initial_content = "mypackage (0.1.0-1) unstable; urgency=medium\n\n * Initial release\n\n -- Maintainer <m@e.com> Wed, 01 Jan 2020 00:00:00 +0000\n";
|
||||||
|
std::fs::write(&changelog_path, initial_content).unwrap();
|
||||||
|
|
||||||
|
Command::new("git")
|
||||||
|
.arg("add")
|
||||||
|
.arg(".")
|
||||||
|
.current_dir(repo_dir)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
commit(repo_dir, "Initial commit");
|
||||||
|
tag(repo_dir, "debian/0.1.0-1");
|
||||||
|
|
||||||
|
commit(repo_dir, "Fix bug A");
|
||||||
|
commit(repo_dir, "Add feature B");
|
||||||
|
|
||||||
|
// Leave an uncommitted newer entry on top
|
||||||
|
let mut unreleased = String::from(
|
||||||
|
"mypackage (0.1.0-2) UNRELEASED; urgency=medium\n\n * WIP.\n\n -- Maintainer <m@e.com> Wed, 01 Jan 2020 00:00:00 +0000\n",
|
||||||
|
);
|
||||||
|
unreleased.push_str(initial_content);
|
||||||
|
std::fs::write(&changelog_path, unreleased).unwrap();
|
||||||
|
|
||||||
|
let _identity = IDENTITY_LOCK.lock().await;
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("DEBFULLNAME", "Maintainer Maintainer");
|
||||||
|
std::env::set_var("DEBEMAIL", "maintainer@maintainer.com");
|
||||||
|
}
|
||||||
|
generate_entry(
|
||||||
|
"debian/changelog",
|
||||||
|
Some(repo_dir),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
EntryKind::Normal,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var("DEBFULLNAME");
|
||||||
|
std::env::remove_var("DEBEMAIL");
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = std::fs::read_to_string(&changelog_path).unwrap();
|
||||||
|
assert!(content.contains("mypackage (0.1.0-3) UNRELEASED; urgency=medium"));
|
||||||
|
assert!(content.contains("* Fix bug A"));
|
||||||
|
assert!(content.contains("* Add feature B"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_compute_new_version() {
|
fn test_compute_new_version() {
|
||||||
// Debian upload
|
// Debian upload
|
||||||
|
|||||||
+142
-2
@@ -19,6 +19,7 @@ use std::time::{Duration, Instant};
|
|||||||
use crossterm::{cursor, execute, style::Stylize, terminal::Clear, terminal::ClearType};
|
use crossterm::{cursor, execute, style::Stylize, terminal::Clear, terminal::ClearType};
|
||||||
use directories::ProjectDirs;
|
use directories::ProjectDirs;
|
||||||
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||||
|
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||||
|
|
||||||
use crate::context::{LineSink, Stream};
|
use crate::context::{LineSink, Stream};
|
||||||
use crate::logfmt::{Action, Classifier, GenericClassifier};
|
use crate::logfmt::{Action, Classifier, GenericClassifier};
|
||||||
@@ -411,19 +412,65 @@ fn push_line(shared: &Shared, st: &mut Pipeline, kind: Kind, text: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the pane content with per-kind colors
|
/// Render the pane content with per-kind colors, ellipsizing lines that are
|
||||||
|
/// wider than the terminal so they do not overflow onto a wrapped line
|
||||||
fn render_pane(lines: &VecDeque<(Kind, String)>) -> String {
|
fn render_pane(lines: &VecDeque<(Kind, String)>) -> String {
|
||||||
|
let max_width = terminal_width().map(|w| w.saturating_sub(PANE_PREFIX_WIDTH));
|
||||||
|
render_pane_with_width(lines, max_width)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`render_pane`] with the available pane width injected (in display
|
||||||
|
/// columns); `None` means the terminal size is unknown and lines are kept whole
|
||||||
|
fn render_pane_with_width(lines: &VecDeque<(Kind, String)>, max_width: Option<usize>) -> String {
|
||||||
lines
|
lines
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(kind, text)| match kind {
|
.map(|(kind, text)| {
|
||||||
|
let text = match max_width {
|
||||||
|
Some(width) => ellipsize(text, width),
|
||||||
|
None => text.clone(),
|
||||||
|
};
|
||||||
|
match kind {
|
||||||
Kind::Normal => format!(" │ {text}"),
|
Kind::Normal => format!(" │ {text}"),
|
||||||
Kind::Warning => format!(" │ {}", text.as_str().yellow()),
|
Kind::Warning => format!(" │ {}", text.as_str().yellow()),
|
||||||
Kind::Error => format!(" │ {}", text.as_str().red()),
|
Kind::Error => format!(" │ {}", text.as_str().red()),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n")
|
.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Display width of the ` │ ` prefix rendered before each pane line
|
||||||
|
const PANE_PREFIX_WIDTH: usize = 4;
|
||||||
|
|
||||||
|
/// Width of the terminal in columns, or `None` when it cannot be determined
|
||||||
|
fn terminal_width() -> Option<usize> {
|
||||||
|
crossterm::terminal::size()
|
||||||
|
.ok()
|
||||||
|
.map(|(cols, _)| cols as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ellipsize `text` to at most `max_width` display columns, keeping its head
|
||||||
|
/// and appending `…` when it does not fit
|
||||||
|
fn ellipsize(text: &str, max_width: usize) -> String {
|
||||||
|
if UnicodeWidthStr::width(text) <= max_width {
|
||||||
|
return text.to_string();
|
||||||
|
}
|
||||||
|
// Reserve one column for the ellipsis itself
|
||||||
|
let budget = max_width.saturating_sub(1);
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut width = 0;
|
||||||
|
for ch in text.chars() {
|
||||||
|
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
|
||||||
|
if width + w > budget {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out.push(ch);
|
||||||
|
width += w;
|
||||||
|
}
|
||||||
|
out.push('…');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Status bar style while no determinate progress is known
|
/// Status bar style while no determinate progress is known
|
||||||
///
|
///
|
||||||
/// The target lives on the first line and the current phase/message on its own
|
/// The target lives on the first line and the current phase/message on its own
|
||||||
@@ -516,3 +563,96 @@ extern "C" fn on_sigint(_sig: libc::c_int) {
|
|||||||
libc::_exit(130);
|
libc::_exit(130);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ellipsize_keeps_short_lines() {
|
||||||
|
assert_eq!(ellipsize("short", 10), "short");
|
||||||
|
assert_eq!(ellipsize("exactly10!", 10), "exactly10!");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ellipsize_truncates_long_lines_to_the_width_budget() {
|
||||||
|
let out = ellipsize("a very long build line that overflows", 20);
|
||||||
|
assert_eq!(UnicodeWidthStr::width(out.as_str()), 20);
|
||||||
|
assert!(out.ends_with('…'));
|
||||||
|
assert!(out.starts_with("a very long build"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ellipsize_never_exceeds_the_budget_with_wide_characters() {
|
||||||
|
let out = ellipsize("wíth émojis 🎉 and 文字 mixing", 12);
|
||||||
|
assert!(UnicodeWidthStr::width(out.as_str()) <= 12);
|
||||||
|
assert!(out.ends_with('…'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ellipsize_degenerate_width_still_terminates() {
|
||||||
|
assert_eq!(ellipsize("overflowing", 0), "…");
|
||||||
|
assert_eq!(ellipsize("overflowing", 1), "…");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pane_lines_are_ellipsized_but_keep_their_prefix_and_color() {
|
||||||
|
let mut lines = VecDeque::new();
|
||||||
|
lines.push_back((
|
||||||
|
Kind::Normal,
|
||||||
|
"gcc -DHAVE_CONFIG_H -I. -I.. -g -O2 -c hello.c".to_string(),
|
||||||
|
));
|
||||||
|
lines.push_back((
|
||||||
|
Kind::Error,
|
||||||
|
"an error much too long for the pane".to_string(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let rendered = render_pane_with_width(&lines, Some(20));
|
||||||
|
|
||||||
|
let rendered = rendered.lines().collect::<Vec<_>>();
|
||||||
|
assert_eq!(rendered.len(), 2);
|
||||||
|
// The injected budget is the text width; every rendered line stays
|
||||||
|
// within the simulated terminal width (prefix + budget)
|
||||||
|
for line in &rendered {
|
||||||
|
let plain = strip_ansi(line);
|
||||||
|
assert!(
|
||||||
|
UnicodeWidthStr::width(plain.as_str()) <= 20 + PANE_PREFIX_WIDTH,
|
||||||
|
"{plain}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(strip_ansi(rendered[0]).starts_with(" │ gcc -DHAVE_CONFIG_H"));
|
||||||
|
// The error keeps its color wrapping around the ellipsized text
|
||||||
|
assert!(rendered[1].contains('\x1b'), "{rendered:?}");
|
||||||
|
assert!(strip_ansi(rendered[1]).starts_with(" │ an error much too l…"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pane_lines_are_kept_whole_without_a_known_terminal_size() {
|
||||||
|
let mut lines = VecDeque::new();
|
||||||
|
lines.push_back((
|
||||||
|
Kind::Normal,
|
||||||
|
"a line that would overflow a narrow pane".to_string(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let rendered = render_pane_with_width(&lines, None);
|
||||||
|
assert!(rendered.contains("a line that would overflow a narrow pane"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort ANSI escape stripper, enough for the assertions above
|
||||||
|
fn strip_ansi(line: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut chars = line.chars();
|
||||||
|
while let Some(ch) = chars.next() {
|
||||||
|
if ch == '\x1b' {
|
||||||
|
for esc in chars.by_ref() {
|
||||||
|
if esc.is_ascii_alphabetic() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user