use chrono::{DateTime, Utc};
use crate::app::Awaited;
use crate::model::join::BeadKey;
pub mod bindings;
pub mod draw;
pub mod fitted;
pub mod forest;
pub mod lines;
pub mod markdown;
pub mod palette;
pub mod phrase;
pub mod row;
pub mod sgr;
pub mod show;
pub mod tail;
#[cfg(test)]
pub(crate) mod painted;
#[cfg(test)]
pub(crate) mod walk;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Motion {
PreviousRow,
NextRow,
HalfScreenUp,
HalfScreenDown,
FirstRow,
LastRow,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
Move(Motion),
CollapseOrParent,
ExpandOrChild,
ToggleFold,
ExpandSubtree,
CollapseSubtree,
RestoreDefault,
ToggleFilter,
Focus,
ShowBead,
NextRelated,
Back,
CopyId,
ShowBindings,
Search,
NextMatch,
PreviousMatch,
Refresh,
Quit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Typing {
Character(char),
RubbedOut,
Sought,
Abandoned,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Said {
Copied(String),
NothingMatched(String),
Matched { key: BeadKey, at: usize, of: usize },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Notice {
AgentsUnknown,
SessionUnanswered(String),
NoInboundChannel,
AnotherBdiHadTheInboundChannel,
ConfigWouldNotReload,
ProjectNamedWithoutGit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Freshness {
pub mark: Mark,
pub read_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mark {
Collecting,
Unanswered,
Read,
Refused,
}
impl Mark {
fn at_rest(every_root_read: bool) -> Self {
if every_root_read {
Mark::Read
} else {
Mark::Refused
}
}
}
impl Freshness {
pub fn of(
read_at: Option<DateTime<Utc>>,
collecting: Option<&Awaited>,
every_root_read: bool,
now: DateTime<Utc>,
) -> Option<Self> {
if read_at.is_none() && collecting.is_none() {
return None;
}
Some(Freshness {
mark: match collecting {
Some(awaited) if awaited.unanswered_at(now) => Mark::Unanswered,
Some(_) => Mark::Collecting,
None => Mark::at_rest(every_root_read),
},
read_at,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::Wanted;
use crate::collect::run::FailureKind;
use crate::model::join::JoinSource;
use crate::model::snapshot::TrackerFailure;
use crate::model::types::testing::an_unreadable;
use chrono::{TimeDelta, TimeZone};
use pretty_assertions::assert_eq;
pub(super) fn says(text: &str, words: &str) {
assert!(
!words.is_empty(),
"every text says nothing, so nothing is asserted"
);
assert!(text.contains(words), "{text:?} does not say {words:?}");
}
pub(super) fn does_not_say(text: &str, words: &str) {
assert!(
!words.is_empty(),
"no text leaves nothing out, so nothing is asserted"
);
assert!(!text.contains(words), "{text:?} says {words:?}");
}
#[test]
#[should_panic(expected = "nothing is asserted")]
fn nothing_is_not_something_a_text_can_say() {
says("⚠ agents unknown", "");
}
#[test]
#[should_panic(expected = "nothing is asserted")]
fn nothing_is_not_something_a_text_can_leave_out() {
does_not_say("⚠ agents unknown", "");
}
pub(super) fn every_failure_kind() -> impl Iterator<Item = FailureKind> {
std::iter::successors(Some(FailureKind::Auth), |kind| match kind {
FailureKind::Auth => Some(FailureKind::Unavailable),
FailureKind::Unavailable => Some(FailureKind::Gone),
FailureKind::Gone => Some(FailureKind::Busy),
FailureKind::Busy => Some(FailureKind::NotInstalled),
FailureKind::NotInstalled => Some(FailureKind::Unstartable),
FailureKind::Unstartable => Some(FailureKind::InstalledUnstartable),
FailureKind::InstalledUnstartable => Some(FailureKind::Parse),
FailureKind::Parse => Some(FailureKind::Unsupported),
FailureKind::Unsupported => Some(FailureKind::UnknownFlag),
FailureKind::UnknownFlag => None,
})
}
pub(super) fn every_tracker_failure() -> impl Iterator<Item = TrackerFailure> {
std::iter::successors(
Some(TrackerFailure::NoEnvironment),
|failure| match failure {
TrackerFailure::NoEnvironment => Some(TrackerFailure::NoCredential),
TrackerFailure::NoCredential => Some(TrackerFailure::Auth),
TrackerFailure::Auth => Some(TrackerFailure::Unavailable),
TrackerFailure::Unavailable => Some(TrackerFailure::NotInstalled),
TrackerFailure::NotInstalled => Some(TrackerFailure::Unstartable),
TrackerFailure::Unstartable => Some(TrackerFailure::InstalledUnstartable),
TrackerFailure::InstalledUnstartable => {
Some(TrackerFailure::Parse(an_unreadable()))
}
TrackerFailure::Parse(_) => Some(TrackerFailure::UnknownFlag),
TrackerFailure::UnknownFlag => None,
},
)
}
pub(super) fn every_notice() -> impl Iterator<Item = Notice> {
std::iter::successors(Some(Notice::AgentsUnknown), |fact| match fact {
Notice::AgentsUnknown => Some(Notice::SessionUnanswered("a session".to_string())),
Notice::SessionUnanswered(_) => Some(Notice::NoInboundChannel),
Notice::NoInboundChannel => Some(Notice::AnotherBdiHadTheInboundChannel),
Notice::AnotherBdiHadTheInboundChannel => Some(Notice::ConfigWouldNotReload),
Notice::ConfigWouldNotReload => Some(Notice::ProjectNamedWithoutGit),
Notice::ProjectNamedWithoutGit => None,
})
}
pub(super) fn every_said() -> impl Iterator<Item = Said> {
std::iter::successors(Some(Said::Copied("grv-1".to_string())), |said| match said {
Said::Copied(_) => Some(Said::NothingMatched("grv-404".to_string())),
Said::NothingMatched(_) => Some(Said::Matched {
key: BeadKey {
project: "orbital".to_string(),
id: "grv-1".to_string(),
},
at: 1,
of: 2,
}),
Said::Matched { .. } => None,
})
}
pub(super) fn every_mark() -> impl Iterator<Item = Mark> {
std::iter::successors(Some(Mark::Collecting), |mark| match mark {
Mark::Collecting => Some(Mark::Unanswered),
Mark::Unanswered => Some(Mark::Read),
Mark::Read => Some(Mark::Refused),
Mark::Refused => None,
})
}
pub(super) fn every_join_source() -> impl Iterator<Item = JoinSource> {
std::iter::successors(Some(JoinSource::AgentPane), |source| match source {
JoinSource::AgentPane => Some(JoinSource::DisplayAgent),
JoinSource::DisplayAgent => None,
})
}
fn at(minute: u32, second: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 8, 30, 10, minute, second)
.unwrap()
}
const PATIENCE: TimeDelta = TimeDelta::seconds(30);
fn asked_at(asked_at: DateTime<Utc>) -> Awaited {
Awaited {
wanted: Wanted::Everything,
asked_at,
patience: PATIENCE,
}
}
#[test]
fn a_project_no_collection_is_reading_says_when_it_was_last_read() {
assert_eq!(
Freshness::of(Some(at(22, 14)), None, true, at(22, 20)),
Some(Freshness {
mark: Mark::Read,
read_at: Some(at(22, 14)),
})
);
}
#[test]
fn a_project_being_read_now_keeps_the_age_of_the_rows_still_on_the_screen() {
assert_eq!(
Freshness::of(
Some(at(22, 14)),
Some(&asked_at(at(22, 20))),
true,
at(22, 20)
),
Some(Freshness {
mark: Mark::Collecting,
read_at: Some(at(22, 14)),
})
);
}
#[test]
fn a_collection_being_awaited_takes_the_mark_from_the_read_it_is_replacing() {
assert_eq!(
Freshness::of(
Some(at(22, 14)),
Some(&asked_at(at(22, 20))),
false,
at(22, 20)
)
.map(|it| it.mark),
Some(Mark::Collecting)
);
}
#[test]
fn a_project_with_a_root_that_would_not_read_rests_on_the_refused_mark() {
assert_eq!(
Freshness::of(Some(at(22, 14)), None, false, at(22, 20)).map(|it| it.mark),
Some(Mark::Refused)
);
}
#[test]
fn a_project_never_read_but_being_read_now_still_says_it_is_collecting() {
assert_eq!(
Freshness::of(None, Some(&asked_at(at(22, 20))), true, at(22, 20)),
Some(Freshness {
mark: Mark::Collecting,
read_at: None,
})
);
}
#[test]
fn a_collection_that_has_stopped_answering_is_said_to_have_rather_than_to_be_running() {
let since = at(22, 20);
assert_eq!(
Freshness::of(
Some(at(22, 14)),
Some(&asked_at(since)),
true,
since + PATIENCE
)
.map(|it| it.mark),
Some(Mark::Unanswered)
);
}
#[test]
fn a_collection_still_inside_the_deadline_is_said_to_be_running() {
let since = at(22, 20);
assert_eq!(
Freshness::of(
Some(at(22, 14)),
Some(&asked_at(since)),
true,
since + PATIENCE - TimeDelta::milliseconds(1)
)
.map(|it| it.mark),
Some(Mark::Collecting)
);
}
#[test]
fn a_collection_that_has_stopped_answering_still_dates_the_rows_on_the_screen() {
let since = at(22, 20);
assert_eq!(
Freshness::of(
Some(at(22, 14)),
Some(&asked_at(since)),
true,
since + PATIENCE
),
Some(Freshness {
mark: Mark::Unanswered,
read_at: Some(at(22, 14)),
})
);
}
#[test]
fn a_first_collection_that_never_answers_says_so_over_no_rows_at_all() {
let since = at(22, 20);
assert_eq!(
Freshness::of(None, Some(&asked_at(since)), true, since + PATIENCE),
Some(Freshness {
mark: Mark::Unanswered,
read_at: None,
})
);
}
#[test]
fn a_collection_that_has_stopped_answering_takes_the_mark_from_the_read_it_is_replacing() {
let since = at(22, 20);
assert_eq!(
Freshness::of(
Some(at(22, 14)),
Some(&asked_at(since)),
false,
since + PATIENCE
)
.map(|it| it.mark),
Some(Mark::Unanswered)
);
}
#[test]
fn a_project_neither_read_nor_being_read_says_nothing_about_freshness() {
assert_eq!(Freshness::of(None, None, true, at(22, 20)), None);
}
}