Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ The `cli/` directory is a Cargo workspace.
- `crates/hm-exec/` — the `ExecutionBackend` trait + `LocalDockerBackend` (in-process Docker DAG scheduler) + `CloudBackend` (submit+watch over the SDK). The `hm` binary renders the emitted `BuildEvent` stream (via `hm-render`) and owns Ctrl-C; auth is injected (the crate takes a built `HarmontClient`).
- `crates/hm-render/` — `drive_stream`: consumes an `EventStream` and writes terminal/JSON output. No I/O beyond stdout.
- `crates/hm-pipeline-ir/` — pipeline IR schema (serde structs only, no runtime).
- `crates/hm-util/` — shared OS and filesystem utilities.
- `crates/hm-common/` — shared utilities (OS/filesystem, formatting, and other cross-crate helpers). This is the source of truth for common code; prefer adding shared helpers here.
- `crates/hm-plugin-protocol/` — wire types (serde structs only).
- `crates/hm-plugin-sdk/` — authoring SDK for plugin writers.
Run `cargo build` from the workspace root.
Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "registry"] }
rstest = "0.23"
proptest = "1"
unicode-width = "0.2"
unicode-segmentation = "1"

[workspace.lints.rust]
unsafe_code = "deny"
Expand Down
2 changes: 2 additions & 0 deletions crates/hm-common/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ num-traits = { workspace = true }
tempfile = "3"
tokio = { version = "1", features = ["process", "fs"] }
tracing = "0.1"
unicode-width = { workspace = true }
unicode-segmentation = { workspace = true }
which = "7"

[dev-dependencies]
Expand Down
1 change: 1 addition & 0 deletions crates/hm-common/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@

pub mod format;
pub mod fs;
pub mod string;
pub mod time;
43 changes: 43 additions & 0 deletions crates/hm-common/src/string.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//! String-related utilities and extension traits.
//!
//! [`align`] and [`ellipsis`] are ported from atuin
//! (`crates/atuin-common/src/string/`), MIT-licensed. See
//! <https://github.com/atuinsh/atuin>. Upstream gates these behind a `unicode`
//! Cargo feature; here they are always compiled.

pub mod align;
pub mod ellipsis;
pub mod escape;

pub use align::{AlignExt, Alignment};
pub use ellipsis::{EllipsizeExt, Ellipsized, Indicator, Pos};
pub use escape::EscapeNonPrintablePosixExt;

use unicode_width::UnicodeWidthStr;

/// How much room to truncate or pad into, and the unit it is measured in.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Measure {
/// A UTF-8 byte budget.
Bytes(usize),
/// A display-column budget via `unicode-width` - a double-width glyph such
/// as `世` or `🦀` counts as two. Use for presentation.
Columns(usize),
}

impl Measure {
/// The numeric limit, in this budget's own unit.
pub(crate) const fn amount(self) -> usize {
match self {
Self::Bytes(n) | Self::Columns(n) => n,
}
}

/// Total cost of `s` in this budget's unit.
pub(crate) fn cost(self, s: &str) -> usize {
match self {
Self::Bytes(_) => s.len(),
Self::Columns(_) => s.width(),
}
}
}
99 changes: 99 additions & 0 deletions crates/hm-common/src/string/align.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//! Extension trait for padding a string to a budget with an alignment.
//!
//! Ported from atuin (`crates/atuin-common/src/string/align.rs`), MIT-licensed.
//! Upstream: <https://github.com/atuinsh/atuin>.

use std::borrow::Cow;

use super::Measure;

/// Which side to pad toward when the string is shorter than the budget.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Alignment {
/// Content flush to the start, padding on the end (left-aligned).
Start,
/// Content centered, padding split across both sides.
Center,
/// Content flush to the end, padding on the start (right-aligned).
End,
}

pub trait AlignExt: AsRef<str> {
/// Pad `self` with spaces to fill `budget`, distributing the padding per `align`.
///
/// Does not truncate.
fn pad_to(&self, budget: Measure, align: Alignment) -> Cow<'_, str> {
let s = self.as_ref();
let pad = budget.amount().saturating_sub(budget.cost(s));
if pad == 0 {
return Cow::Borrowed(s);
}
// Split the padding across the two sides per `align`.
let (left, right) = match align {
Alignment::Start => (0, pad),
Alignment::End => (pad, 0),
Alignment::Center => (pad / 2, pad - pad / 2),
};
let mut out = String::with_capacity(s.len() + pad);
for _ in 0..left {
out.push(' ');
}
out.push_str(s);
for _ in 0..right {
out.push(' ');
}
Cow::Owned(out)
}
}

impl<T: AsRef<str>> AlignExt for T {}

#[cfg(test)]
mod tests {
use super::{AlignExt, Alignment};
use crate::string::Measure;
use rstest::rstest;

#[rstest]
#[case::start_pads_on_the_end("hi", Measure::Columns(5), Alignment::Start, "hi ")]
#[case::end_pads_on_the_start("hi", Measure::Columns(5), Alignment::End, " hi")]
#[case::center_splits_evenly("hi", Measure::Columns(6), Alignment::Center, " hi ")]
#[case::center_odd_extra_on_right("hi", Measure::Columns(5), Alignment::Center, " hi ")]
#[case::exact_fit_unchanged("hello", Measure::Columns(5), Alignment::Start, "hello")]
#[case::empty_pads("", Measure::Columns(3), Alignment::End, " ")]
#[case::wide_glyph_pads_by_display_columns("世", Measure::Columns(3), Alignment::Start, "世 ")]
#[case::pads_by_bytes_under_byte_budget("世", Measure::Bytes(4), Alignment::Start, "世 ")]
#[case::too_wide_is_never_truncated(
"hello world",
Measure::Columns(3),
Alignment::Start,
"hello world"
)]
fn pads_per_table(
#[case] input: &str,
#[case] budget: Measure,
#[case] align: Alignment,
#[case] expected: &str,
) {
assert_eq!(input.pad_to(budget, align).as_ref(), expected);
}

#[rstest]
fn borrows_only_when_no_padding_needed() {
// Exact fit: no padding -> borrowed.
assert!(matches!(
"hello".pad_to(Measure::Columns(5), Alignment::Start),
std::borrow::Cow::Borrowed(_)
));
// Too wide: not truncated, no padding -> borrowed.
assert!(matches!(
"hello world".pad_to(Measure::Columns(3), Alignment::Start),
std::borrow::Cow::Borrowed(_)
));
// Padding needed -> owned.
assert!(matches!(
"hi".pad_to(Measure::Columns(5), Alignment::Start),
std::borrow::Cow::Owned(_)
));
}
}
Loading
Loading