use std::fmt::Display;
use std::time::Duration;
use chrono::Local;
use crate::logs;
const MAX_DETAIL: usize = 100;
const PREFIX: &str = "thirdshift: ";
const STAMP: &str = "%H:%M:%S";
pub fn step(message: impl Display) {
logs::eprint(&stamped(message));
}
pub fn duration(duration: Duration) -> String {
let seconds = duration.as_secs();
let (hours, minutes, seconds) = (seconds / 3600, seconds / 60 % 60, seconds % 60);
if hours > 0 {
format!("{hours}h {minutes}m {seconds}s")
} else if minutes > 0 {
format!("{minutes}m {seconds}s")
} else {
format!("{seconds}s")
}
}
pub fn warn(error: &anyhow::Error, warning: std::fmt::Arguments) {
step(format_args!("{error:#}"));
step(format_args!("warning: {warning}"));
}
pub fn stamped(message: impl Display) -> String {
progress_line(Local::now().format(STAMP), message)
}
#[derive(Debug, PartialEq, Eq)]
pub enum ChildLine<'a> {
Own(&'a str),
Relayed,
Continuation,
}
impl<'a> ChildLine<'a> {
pub fn of(line: &'a str) -> Self {
let Some(rest) = line.strip_prefix(PREFIX) else {
return ChildLine::Continuation;
};
let message = split_stamp(rest).map_or(rest, |(_, message)| message);
let from_its_own_child = message
.strip_prefix('#')
.and_then(|rest| rest.split_once(": "))
.is_some_and(|(number, _)| {
!number.is_empty() && number.bytes().all(|byte| byte.is_ascii_digit())
});
if from_its_own_child {
ChildLine::Relayed
} else {
ChildLine::Own(message)
}
}
}
pub fn relay(number: u64, line: &str) -> ChildLine<'_> {
logs::eprint(&relayed(number, line));
ChildLine::of(line)
}
pub fn relayed(number: u64, line: &str) -> String {
let rest = line.strip_prefix(PREFIX).unwrap_or(line);
match split_stamp(rest) {
Some((time, message)) => progress_line(time, format_args!("#{number}: {message}")),
None => stamped(format_args!("#{number}: {rest}")),
}
}
pub(crate) fn progress_line(time: impl Display, message: impl Display) -> String {
format!("{PREFIX}{time} {message}")
}
fn split_stamp(line: &str) -> Option<(&str, &str)> {
let (time, message) = line.split_at_checked(8)?;
let message = message.strip_prefix(' ')?;
let is_stamp = time.bytes().enumerate().all(|(at, byte)| match at {
2 | 5 => byte == b':',
_ => byte.is_ascii_digit(),
});
is_stamp.then_some((time, message))
}
pub(crate) fn bash(command: &str) -> String {
let actions: Vec<&str> = ["commit", "push"]
.into_iter()
.filter(|subcommand| runs_git(command, subcommand))
.collect();
if actions.is_empty() {
format!("$ {}", shorten(command.lines().next().unwrap_or("")))
} else {
actions.join(" and ")
}
}
fn runs_git(command: &str, subcommand: &str) -> bool {
command.split(['\n', ';', '&', '|']).any(|part| {
let mut words = part.split_whitespace();
words.next() == Some("git") && words.next() == Some(subcommand)
})
}
pub(crate) fn shorten(text: &str) -> String {
match text.char_indices().nth(MAX_DETAIL) {
Some((cut, _)) => format!("{}…", &text[..cut]),
None => text.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn durations_use_whole_seconds_across_minute_and_hour_boundaries() {
for (millis, text) in [
(0, "0s"),
(999, "0s"),
(1_000, "1s"),
(59_999, "59s"),
(60_000, "1m 0s"),
(61_000, "1m 1s"),
(3_599_999, "59m 59s"),
(3_600_000, "1h 0m 0s"),
(3_723_000, "1h 2m 3s"),
(90_061_000, "25h 1m 1s"),
] {
assert_eq!(duration(Duration::from_millis(millis)), text);
}
}
#[test]
fn a_stamped_line_splits_into_its_time_and_message() {
assert_eq!(
split_stamp("12:14:49 pushing issue-7"),
Some(("12:14:49", "pushing issue-7"))
);
}
#[test]
fn a_line_without_a_stamp_does_not_split() {
for line in [
"pushing issue-7",
"12:14:49",
"12:14:49pushing",
"12-14-49 pushing",
"1:14:49 pushing",
"#21: 12:14:49 pushing",
"é2:14:49 pushing",
] {
assert_eq!(split_stamp(line), None, "{line}");
}
}
#[test]
fn a_relayed_line_keeps_its_time_under_the_issues_number() {
assert_eq!(
relayed(21, "thirdshift: 12:14:49 could not push issue-21"),
"thirdshift: 12:14:49 #21: could not push issue-21"
);
}
#[test]
fn a_relayed_line_with_no_time_is_stamped_now() {
let line = relayed(21, "hint: fetch first");
let (_, message) = split_stamp(line.strip_prefix(PREFIX).unwrap()).unwrap();
assert_eq!(message, "#21: hint: fetch first");
}
#[test]
fn a_childs_line_that_starts_with_the_prefix_is_its_own_progress_line() {
assert_eq!(
ChildLine::of("thirdshift: 12:14:49 could not push issue-21"),
ChildLine::Own("could not push issue-21")
);
assert_eq!(
ChildLine::of(&stamped("#21 failed: claude exited 1")),
ChildLine::Own("#21 failed: claude exited 1")
);
}
#[test]
fn a_childs_line_without_the_prefix_continues_the_one_before() {
for line in [
"hint: fetch first",
"12:14:49 thirdshift: pushing",
" thirdshift: 12:14:49 pushing",
"#8: claude exited 1",
"",
] {
assert_eq!(ChildLine::of(line), ChildLine::Continuation, "{line}");
}
}
#[test]
fn a_line_a_child_relayed_is_not_its_own_progress_line() {
for line in [
"thirdshift: 12:14:49 claude exited 1",
"hint: fetch first",
"thirdshift: 12:14:49 #9: relayed before",
] {
assert_eq!(
ChildLine::of(&relayed(8, line)),
ChildLine::Relayed,
"{line}"
);
}
}
}