use std::fmt;
use std::path::Path;
use std::time::Duration;
use serde::Serialize;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::fs::Fs;
use crate::paths::Pather;
use crate::shell::rc::{self, HookPresence};
pub const INIT_GEN_ENV: &str = "DODOT_INIT_GEN";
pub const INIT_VERSION_ENV: &str = "DODOT_INIT_VERSION";
pub const PRE_VERSION_RELEASE: &str = "5.5.1";
pub fn running_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
pub fn current_generation() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn parse_generation(raw: &str) -> Option<u64> {
raw.trim().parse::<u64>().ok()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EvidenceVersion {
Known(String),
PreVersion,
}
impl EvidenceVersion {
pub fn from_field(field: Option<&str>) -> EvidenceVersion {
match field.map(str::trim).filter(|v| !v.is_empty()) {
Some(v) => EvidenceVersion::Known(v.to_string()),
None => EvidenceVersion::PreVersion,
}
}
pub fn is(&self, running: &str) -> bool {
match self {
EvidenceVersion::Known(v) => v == running,
EvidenceVersion::PreVersion => running == PRE_VERSION_RELEASE,
}
}
}
pub fn is_skewed(loaded: Option<&EvidenceVersion>, running: &str) -> bool {
loaded.is_some_and(|v| !v.is(running))
}
impl fmt::Display for EvidenceVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
EvidenceVersion::Known(v) => f.write_str(v),
EvidenceVersion::PreVersion => write!(f, "≤{PRE_VERSION_RELEASE}"),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EnvStamp {
pub generation: Option<u64>,
pub version: Option<String>,
}
impl EnvStamp {
pub fn read() -> EnvStamp {
EnvStamp {
generation: std::env::var(INIT_GEN_ENV)
.ok()
.as_deref()
.and_then(parse_generation),
version: std::env::var(INIT_VERSION_ENV)
.ok()
.filter(|v| !v.trim().is_empty()),
}
}
pub fn at(generation: u64) -> EnvStamp {
EnvStamp {
generation: Some(generation),
version: None,
}
}
pub fn evidence_version(&self) -> Option<EvidenceVersion> {
self.generation
.map(|_| EvidenceVersion::from_field(self.version.as_deref()))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Heartbeat {
pub generation: u64,
pub version: Option<String>,
pub last_run: Option<SystemTime>,
}
impl Heartbeat {
pub fn evidence_version(&self) -> EvidenceVersion {
EvidenceVersion::from_field(self.version.as_deref())
}
}
pub fn parse_heartbeat(raw: &str) -> (Option<u64>, Option<String>) {
let mut fields = raw.split_whitespace();
let generation = fields.next().and_then(parse_generation);
let version = fields.next().map(str::to_string);
(generation, version)
}
pub fn read_heartbeat(fs: &dyn Fs, paths: &dyn Pather) -> Option<Heartbeat> {
let path = paths.hookup_heartbeat_path();
if !fs.exists(&path) {
return None;
}
let raw = fs.read_to_string(&path).ok()?;
let (generation, version) = parse_heartbeat(&raw);
Some(Heartbeat {
generation: generation?,
version,
last_run: fs.modified(&path).ok(),
})
}
pub fn read_script(fs: &dyn Fs, paths: &dyn Pather) -> Option<String> {
let path = paths.init_script_path();
if !fs.exists(&path) {
return None;
}
fs.read_to_string(&path).ok()
}
pub fn read_script_generation(fs: &dyn Fs, paths: &dyn Pather) -> Option<u64> {
read_script(fs, paths)
.as_deref()
.and_then(parse_script_generation)
}
pub fn parse_script_generation(script: &str) -> Option<u64> {
let prefix = format!("export {INIT_GEN_ENV}=");
script
.lines()
.find_map(|line| line.trim().strip_prefix(&prefix))
.and_then(parse_generation)
}
pub fn parse_script_version(script: &str) -> Option<String> {
let prefix = format!("export {INIT_VERSION_ENV}=");
script
.lines()
.find_map(|line| line.trim().strip_prefix(&prefix))
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StampState {
Current,
Stale,
Absent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HeartbeatState {
Fresh,
Old,
Absent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActivationState {
Healthy,
VersionSkew,
EmptyScript,
StaleShell,
NeverActivated,
ShellNotLoaded,
VerifiedBroken,
}
impl ActivationState {
pub fn as_str(self) -> &'static str {
match self {
ActivationState::Healthy => "healthy",
ActivationState::VersionSkew => "version-skew",
ActivationState::EmptyScript => "empty-script",
ActivationState::StaleShell => "stale-shell",
ActivationState::NeverActivated => "never-activated",
ActivationState::ShellNotLoaded => "shell-not-loaded",
ActivationState::VerifiedBroken => "verified-broken",
}
}
}
pub fn classify_stamp(stamp: Option<u64>, reference: Option<u64>) -> StampState {
match (stamp, reference) {
(None, _) => StampState::Absent,
(Some(_), None) => StampState::Current,
(Some(s), Some(r)) => {
if s >= r {
StampState::Current
} else {
StampState::Stale
}
}
}
}
pub fn classify_heartbeat(heartbeat: Option<u64>, reference: Option<u64>) -> HeartbeatState {
match (heartbeat, reference) {
(None, _) => HeartbeatState::Absent,
(Some(_), None) => HeartbeatState::Fresh,
(Some(h), Some(r)) => {
if h >= r {
HeartbeatState::Fresh
} else {
HeartbeatState::Old
}
}
}
}
pub fn refine(
ladder: ActivationState,
skewed: bool,
script_has_contributions: bool,
) -> ActivationState {
match ladder {
ActivationState::Healthy | ActivationState::StaleShell if skewed => {
ActivationState::VersionSkew
}
ActivationState::Healthy if !script_has_contributions => ActivationState::EmptyScript,
other => other,
}
}
pub fn evaluate(stamp: StampState, heartbeat: HeartbeatState, tty: bool) -> ActivationState {
match (stamp, heartbeat) {
(StampState::Current, _) => ActivationState::Healthy,
(StampState::Stale, _) => ActivationState::StaleShell,
(StampState::Absent, HeartbeatState::Absent) => ActivationState::NeverActivated,
(StampState::Absent, _) if tty => ActivationState::ShellNotLoaded,
(StampState::Absent, HeartbeatState::Fresh) => ActivationState::Healthy,
(StampState::Absent, HeartbeatState::Old) => ActivationState::StaleShell,
}
}
#[derive(Debug, Clone)]
pub struct Evidence {
pub stamp: EnvStamp,
pub heartbeat: Option<Heartbeat>,
pub reference: Option<u64>,
pub script_has_contributions: bool,
pub tty: bool,
pub running_version: String,
pub now: SystemTime,
}
impl Evidence {
pub fn collect(
fs: &dyn Fs,
paths: &dyn Pather,
stamp: EnvStamp,
reference: Option<u64>,
tty: bool,
) -> Option<Evidence> {
let script = read_script(fs, paths)?;
Some(Evidence {
stamp,
heartbeat: read_heartbeat(fs, paths),
reference,
script_has_contributions: crate::shell::script_has_contributions(&script),
tty,
running_version: running_version().to_string(),
now: SystemTime::now(),
})
}
pub fn loaded_version(&self) -> Option<EvidenceVersion> {
self.stamp
.evidence_version()
.or_else(|| self.heartbeat.as_ref().map(Heartbeat::evidence_version))
}
fn skewed(&self) -> bool {
is_skewed(self.loaded_version().as_ref(), &self.running_version)
}
pub fn state(&self) -> ActivationState {
let ladder = evaluate(
classify_stamp(self.stamp.generation, self.reference),
classify_heartbeat(
self.heartbeat.as_ref().map(|h| h.generation),
self.reference,
),
self.tty,
);
refine(ladder, self.skewed(), self.script_has_contributions)
}
pub fn evidence_line(&self) -> String {
let tail = if self.state() == ActivationState::VersionSkew {
format!(" — you are running {}.", self.running_version)
} else {
".".to_string()
};
let when = self.elapsed_since_last_run().map(relative);
let beat = self.heartbeat.as_ref().map(Heartbeat::evidence_version);
match (self.stamp.evidence_version(), beat) {
(None, None) => "Never loaded.".to_string(),
(Some(v), None) | (None, Some(v)) => one_event(when, &v, &tail),
(Some(stamp), Some(beat)) if stamp == beat => one_event(when, &beat, &tail),
(Some(stamp), Some(beat)) => two_events(when, &stamp, &beat, &tail),
}
}
fn elapsed_since_last_run(&self) -> Option<Duration> {
let last_run = self.heartbeat.as_ref()?.last_run?;
self.now.duration_since(last_run).ok()
}
pub fn footer(
&self,
hook_line: &str,
scan: Option<(HookPresence, String)>,
) -> ActivationNotice {
ActivationNotice::for_state(self.state(), hook_line, scan, self.evidence_line())
}
}
fn one_event(when: Option<String>, version: &EvidenceVersion, tail: &str) -> String {
match when {
Some(ago) => format!("Last loaded {ago} by dodot {version}{tail}"),
None => format!("Last loaded at an unknown time by dodot {version}{tail}"),
}
}
fn two_events(
when: Option<String>,
stamp: &EvidenceVersion,
beat: &EvidenceVersion,
tail: &str,
) -> String {
match when {
Some(ago) => format!(
"This shell loaded dodot {stamp}; the last shell to load ran dodot {beat}, {ago}{tail}"
),
None => {
format!(
"This shell loaded dodot {stamp}; the last shell to load ran dodot {beat}{tail}"
)
}
}
}
fn relative(elapsed: Duration) -> String {
let mut formatter = timeago::Formatter::new();
formatter.too_low("just now");
formatter.convert(elapsed)
}
pub fn notice_for(
fs: &dyn Fs,
paths: &dyn Pather,
stamp: EnvStamp,
reference: Option<u64>,
tty: bool,
shell_env: &rc::ShellEnv,
) -> Option<ActivationNotice> {
let evidence = Evidence::collect(fs, paths, stamp, reference, tty)?;
let hook_line = hook_line(&paths.init_script_path(), paths.home_dir());
let scan = (evidence.state() == ActivationState::ShellNotLoaded)
.then(|| rc::scan_expected_rc(fs, paths.home_dir(), shell_env, None))
.flatten();
Some(evidence.footer(&hook_line, scan))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ActivationNotice {
pub state: String,
pub severity: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,
pub evidence: String,
}
impl ActivationNotice {
pub fn for_state(
state: ActivationState,
hook_line: &str,
scan: Option<(HookPresence, String)>,
evidence: String,
) -> ActivationNotice {
let (severity, message, hint): (&str, &str, Option<String>) = match state {
ActivationState::Healthy => ("ok", HEALTHY_MESSAGE, None),
ActivationState::VersionSkew => (
"warning",
"Shell hookup: your shells load a different dodot.",
Some(
"Open a new shell. If that changes nothing, the `dodot` your shells run is \
a different install than the one you just ran — check which one your PATH \
finds first."
.into(),
),
),
ActivationState::EmptyScript => (
"info",
"Shell hookup: wired, but no packs are deployed.",
Some("Run `dodot up` to deploy your packs.".into()),
),
ActivationState::StaleShell => (
"info",
"Shell hookup: this shell predates your last `dodot up`.",
Some("Open a new shell to pick up the current deployment.".into()),
),
ActivationState::NeverActivated => (
"warning",
"Shell hookup: no shell has loaded dodot yet.",
Some(format!(
"Run `dodot install --write` to wire it up, or add this to your shell rc \
file yourself: {hook_line}"
)),
),
ActivationState::ShellNotLoaded => (
shell_not_loaded_severity(&scan),
"Shell hookup: this shell did not load dodot.",
Some(shell_not_loaded_hint(scan, hook_line)),
),
ActivationState::VerifiedBroken => (
"error",
VERIFIED_BROKEN_MESSAGE,
Some(format!(
"Run `dodot install --write` to wire the hook, or add this to your shell rc \
file yourself: {hook_line}"
)),
),
};
ActivationNotice {
state: state.as_str().into(),
severity: severity.into(),
message: message.into(),
hint,
evidence,
}
}
}
fn shell_not_loaded_severity(scan: &Option<(HookPresence, String)>) -> &'static str {
match scan {
Some((HookPresence::Absent, _)) => "warning",
_ => "info",
}
}
fn shell_not_loaded_hint(scan: Option<(HookPresence, String)>, hook_line: &str) -> String {
match scan {
Some((HookPresence::Absent, rc_path)) => format!(
"{rc_path} doesn't have the dodot hook. Run `dodot install --write` to \
wire it up, or add this line yourself: {hook_line}"
),
Some((_, rc_path)) => format!(
"The hook is in {rc_path}, so this shell probably predates it — open a \
new shell. If that changes nothing, run `dodot up` to diagnose."
),
None => format!(
"dodot could not tell which rc file your shell reads. Make sure it has \
this line: {hook_line}"
),
}
}
pub const VERIFIED_BROKEN_MESSAGE: &str = "Shell hookup: a new shell did not load dodot.";
pub const HEALTHY_MESSAGE: &str = "Shell hookup: dodot is sourced in new shells.";
pub fn hook_line(init_script_path: &Path, home: &Path) -> String {
let shown = match init_script_path.strip_prefix(home) {
Ok(rel) => format!("$HOME/{}", rel.display()),
Err(_) => init_script_path.display().to_string(),
};
format!("[ -f \"{shown}\" ] && . \"{shown}\"")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::TempEnvironment;
const NOW: SystemTime = SystemTime::UNIX_EPOCH;
fn ago(seconds: u64) -> Option<SystemTime> {
Some(NOW - Duration::from_secs(seconds))
}
fn heartbeat(raw: &str, last_run: Option<SystemTime>) -> Heartbeat {
let (generation, version) = parse_heartbeat(raw);
Heartbeat {
generation: generation.expect("heartbeat fixture must carry a parseable generation"),
version,
last_run,
}
}
fn skewless() -> Evidence {
Evidence {
stamp: EnvStamp::default(),
heartbeat: Some(heartbeat("100 5.6.0", ago(4 * 60))),
reference: Some(100),
script_has_contributions: true,
tty: false,
running_version: "5.6.0".into(),
now: NOW,
}
}
fn deploy_a_shell_source(env: &TempEnvironment) {
let shell_dir = env.paths.handler_data_dir("vim", "shell");
env.fs.mkdir_all(&shell_dir).unwrap();
let target = env.home.join("aliases.sh");
env.fs.write_file(&target, b"alias v=vim").unwrap();
env.fs
.symlink(&target, &shell_dir.join("aliases.sh"))
.unwrap();
}
fn plant_heartbeat(env: &TempEnvironment, contents: &str) {
env.fs.mkdir_all(&env.paths.probes_hookup_dir()).unwrap();
env.fs
.write_file(&env.paths.hookup_heartbeat_path(), contents.as_bytes())
.unwrap();
}
#[test]
fn stamp_classification_covers_generation_comparisons() {
let cases = [
(None, Some(10), StampState::Absent),
(None, None, StampState::Absent),
(Some(10), Some(10), StampState::Current),
(Some(11), Some(10), StampState::Current),
(Some(9), Some(10), StampState::Stale),
(Some(0), Some(10), StampState::Stale),
(Some(9), None, StampState::Current),
];
for (stamp, reference, expected) in cases {
assert_eq!(
classify_stamp(stamp, reference),
expected,
"stamp={stamp:?} reference={reference:?}"
);
}
}
#[test]
fn heartbeat_classification_covers_generation_comparisons() {
let cases = [
(None, Some(10), HeartbeatState::Absent),
(None, None, HeartbeatState::Absent),
(Some(10), Some(10), HeartbeatState::Fresh),
(Some(11), Some(10), HeartbeatState::Fresh),
(Some(9), Some(10), HeartbeatState::Old),
(Some(9), None, HeartbeatState::Fresh),
];
for (heartbeat, reference, expected) in cases {
assert_eq!(
classify_heartbeat(heartbeat, reference),
expected,
"heartbeat={heartbeat:?} reference={reference:?}"
);
}
}
#[test]
fn evaluation_covers_the_full_stamp_by_heartbeat_by_tty_matrix() {
use ActivationState::*;
use HeartbeatState as H;
use StampState as S;
let matrix = [
(S::Current, H::Fresh, Healthy, Healthy),
(S::Current, H::Old, Healthy, Healthy),
(S::Current, H::Absent, Healthy, Healthy),
(S::Stale, H::Fresh, StaleShell, StaleShell),
(S::Stale, H::Old, StaleShell, StaleShell),
(S::Stale, H::Absent, StaleShell, StaleShell),
(S::Absent, H::Fresh, Healthy, ShellNotLoaded),
(S::Absent, H::Old, StaleShell, ShellNotLoaded),
(S::Absent, H::Absent, NeverActivated, NeverActivated),
];
for (stamp, heartbeat, detached, tty) in matrix {
assert_eq!(
evaluate(stamp, heartbeat, false),
detached,
"detached: stamp={stamp:?} heartbeat={heartbeat:?}"
);
assert_eq!(
evaluate(stamp, heartbeat, true),
tty,
"tty: stamp={stamp:?} heartbeat={heartbeat:?}"
);
}
}
#[test]
fn end_to_end_generation_matrix_through_collected_evidence() {
let cases = [
(Some(100), Some(100), false, ActivationState::Healthy),
(Some(99), Some(100), false, ActivationState::StaleShell),
(None, Some(100), false, ActivationState::Healthy),
(None, Some(99), false, ActivationState::StaleShell),
(None, None, false, ActivationState::NeverActivated),
(Some(100), None, false, ActivationState::Healthy),
(Some(99), None, false, ActivationState::StaleShell),
(None, Some(100), true, ActivationState::ShellNotLoaded),
(None, Some(99), true, ActivationState::ShellNotLoaded),
(None, None, true, ActivationState::NeverActivated),
(Some(100), None, true, ActivationState::Healthy),
];
for (stamp, heartbeat, tty, expected) in cases {
let env = TempEnvironment::builder().build();
deploy_a_shell_source(&env);
crate::shell::write_init_script(env.fs.as_ref(), env.paths.as_ref(), false, None)
.unwrap();
if let Some(h) = heartbeat {
plant_heartbeat(&env, &format!("{h} {}", running_version()));
}
let stamp = EnvStamp {
generation: stamp,
version: Some(running_version().into()),
};
let evidence =
Evidence::collect(env.fs.as_ref(), env.paths.as_ref(), stamp, Some(100), tty)
.expect("a script is on disk");
assert_eq!(
evidence.state(),
expected,
"stamp={:?} heartbeat={heartbeat:?} tty={tty}",
evidence.stamp.generation
);
}
}
#[test]
fn unparseable_signals_read_as_absent_not_zero() {
assert_eq!(parse_generation(" 42\n"), Some(42));
assert_eq!(parse_generation(""), None);
assert_eq!(parse_generation("not-a-number"), None);
assert_eq!(parse_generation("-1"), None);
let env = TempEnvironment::builder().build();
plant_heartbeat(&env, "garbage");
assert_eq!(read_heartbeat(env.fs.as_ref(), env.paths.as_ref()), None);
assert_eq!(
EnvStamp {
generation: None,
version: Some("9.9.9".into()),
}
.evidence_version(),
None
);
}
#[test]
fn a_heartbeat_splits_into_generation_and_version() {
assert_eq!(
parse_heartbeat("1786830419 5.6.0\n"),
(Some(1_786_830_419), Some("5.6.0".into()))
);
assert_eq!(parse_heartbeat("1786830419"), (Some(1_786_830_419), None));
assert_eq!(parse_heartbeat(""), (None, None));
}
#[test]
fn script_generation_is_read_back_from_the_export_line() {
assert_eq!(
parse_script_generation("#!/bin/sh\nexport DODOT_INIT_GEN=1755200000\n"),
Some(1_755_200_000)
);
assert_eq!(parse_script_generation("#!/bin/sh\nexport PATH=x\n"), None);
let env = TempEnvironment::builder().build();
assert_eq!(
read_script_generation(env.fs.as_ref(), env.paths.as_ref()),
None
);
}
#[test]
fn a_version_less_signal_renders_as_the_bound_not_as_unknown() {
assert_eq!(EvidenceVersion::from_field(None).to_string(), "≤5.5.1");
assert_eq!(
EvidenceVersion::from_field(Some(" ")).to_string(),
"≤5.5.1"
);
assert_eq!(
EvidenceVersion::from_field(Some("5.6.0")).to_string(),
"5.6.0"
);
assert!(!EvidenceVersion::PreVersion.is("5.6.0"));
assert!(EvidenceVersion::Known("5.6.0".into()).is("5.6.0"));
assert!(EvidenceVersion::PreVersion.is(PRE_VERSION_RELEASE));
}
#[test]
fn a_version_less_heartbeat_reports_skew_against_the_bound() {
let evidence = Evidence {
stamp: EnvStamp::default(),
heartbeat: Some(heartbeat("1786830419", ago(4 * 60))),
reference: Some(1_786_830_419),
script_has_contributions: true,
tty: false,
running_version: "5.6.0".into(),
now: NOW,
};
assert_eq!(evidence.state(), ActivationState::VersionSkew);
assert_eq!(
evidence.evidence_line(),
"Last loaded 4 minutes ago by dodot ≤5.5.1 — you are running 5.6.0."
);
}
#[test]
fn skew_is_a_version_mismatch_never_staleness_alone() {
let stale = Evidence {
stamp: EnvStamp {
generation: Some(99),
version: Some("5.6.0".into()),
},
..skewless()
};
assert_eq!(stale.state(), ActivationState::StaleShell);
let skewed = Evidence {
stamp: EnvStamp {
generation: Some(100),
version: Some("5.0.0".into()),
},
heartbeat: Some(heartbeat("100 5.0.0", ago(4 * 60))),
..skewless()
};
assert_eq!(skewed.state(), ActivationState::VersionSkew);
assert_eq!(
skewed.evidence_line(),
"Last loaded 4 minutes ago by dodot 5.0.0 — you are running 5.6.0."
);
let never = Evidence {
stamp: EnvStamp::default(),
heartbeat: None,
..skewless()
};
assert_eq!(never.state(), ActivationState::NeverActivated);
assert_eq!(never.evidence_line(), "Never loaded.");
}
#[test]
fn a_disagreement_reports_two_events_never_one_blended_from_both() {
let evidence = Evidence {
stamp: EnvStamp {
generation: Some(100),
version: Some("5.6.0".into()),
},
heartbeat: Some(heartbeat("100 5.0.0", ago(4 * 60))),
..skewless()
};
assert_eq!(evidence.state(), ActivationState::Healthy);
let line = evidence.evidence_line();
assert_eq!(
line,
"This shell loaded dodot 5.6.0; the last shell to load ran dodot 5.0.0, 4 minutes ago."
);
assert!(!line.contains("4 minutes ago by dodot 5.6.0"), "{line}");
}
#[test]
fn a_skewed_stamp_beside_a_newer_heartbeat_keeps_both_events_intact() {
let evidence = Evidence {
stamp: EnvStamp {
generation: Some(100),
version: Some("5.0.0".into()),
},
heartbeat: Some(heartbeat("100 5.6.0", ago(4 * 60))),
..skewless()
};
assert_eq!(evidence.state(), ActivationState::VersionSkew);
assert_eq!(
evidence.evidence_line(),
"This shell loaded dodot 5.0.0; the last shell to load ran dodot 5.6.0, 4 minutes ago \
— you are running 5.6.0."
);
}
#[test]
fn agreeing_signals_render_a_single_event() {
let evidence = Evidence {
stamp: EnvStamp {
generation: Some(100),
version: Some("5.6.0".into()),
},
heartbeat: Some(heartbeat("100 5.6.0", ago(4 * 60))),
..skewless()
};
assert_eq!(
evidence.evidence_line(),
"Last loaded 4 minutes ago by dodot 5.6.0."
);
}
#[test]
fn a_stamp_without_a_heartbeat_reports_an_unknown_time() {
let evidence = Evidence {
stamp: EnvStamp {
generation: Some(100),
version: Some("5.6.0".into()),
},
heartbeat: None,
..skewless()
};
assert_eq!(
evidence.evidence_line(),
"Last loaded at an unknown time by dodot 5.6.0."
);
}
#[test]
fn a_disagreement_without_an_mtime_still_names_both_dodots() {
let evidence = Evidence {
stamp: EnvStamp {
generation: Some(100),
version: Some("5.6.0".into()),
},
heartbeat: Some(heartbeat("100 5.0.0", None)),
..skewless()
};
assert_eq!(
evidence.evidence_line(),
"This shell loaded dodot 5.6.0; the last shell to load ran dodot 5.0.0."
);
}
#[test]
fn last_loaded_comes_from_the_heartbeat_mtime_not_its_contents() {
let env = TempEnvironment::builder().build();
deploy_a_shell_source(&env);
crate::shell::write_init_script(env.fs.as_ref(), env.paths.as_ref(), false, None).unwrap();
plant_heartbeat(&env, &format!("1786830419 {}", running_version()));
let touched = SystemTime::now() - Duration::from_secs(45 * 60);
env.fs
.set_modified(&env.paths.hookup_heartbeat_path(), touched)
.unwrap();
let evidence = Evidence::collect(
env.fs.as_ref(),
env.paths.as_ref(),
EnvStamp::default(),
Some(1_786_830_419),
false,
)
.unwrap();
assert_eq!(
evidence.evidence_line(),
format!("Last loaded 45 minutes ago by dodot {}.", running_version()),
"the generation inside the file is a write time, not a run time"
);
}
#[test]
fn a_corrupt_heartbeat_reads_as_no_activation_on_every_axis() {
let env = TempEnvironment::builder().build();
deploy_a_shell_source(&env);
crate::shell::write_init_script(env.fs.as_ref(), env.paths.as_ref(), false, None).unwrap();
plant_heartbeat(&env, "not-a-generation 5.6.0");
let touched = SystemTime::now() - Duration::from_secs(45 * 60);
env.fs
.set_modified(&env.paths.hookup_heartbeat_path(), touched)
.unwrap();
let evidence = Evidence::collect(
env.fs.as_ref(),
env.paths.as_ref(),
EnvStamp::default(),
Some(1_786_830_419),
false,
)
.unwrap();
assert_eq!(evidence.loaded_version(), None);
assert_eq!(evidence.state(), ActivationState::NeverActivated);
assert_eq!(
evidence.evidence_line(),
"Never loaded.",
"neither the mtime nor the trailing field is evidence a shell loaded dodot"
);
}
#[test]
fn a_corrupt_heartbeat_is_not_a_pre_version_activation() {
let evidence = Evidence {
heartbeat: read_heartbeat_from(""),
..skewless()
};
assert_eq!(evidence.loaded_version(), None);
assert_eq!(evidence.evidence_line(), "Never loaded.");
}
fn read_heartbeat_from(contents: &str) -> Option<Heartbeat> {
let env = TempEnvironment::builder().build();
plant_heartbeat(&env, contents);
read_heartbeat(env.fs.as_ref(), env.paths.as_ref())
}
#[test]
fn an_unreadable_mtime_says_so_instead_of_guessing() {
let evidence = Evidence {
heartbeat: Some(heartbeat("100 5.6.0", None)),
..skewless()
};
assert_eq!(
evidence.evidence_line(),
"Last loaded at an unknown time by dodot 5.6.0."
);
}
#[test]
fn every_state_renders_its_pinned_two_lines() {
let cases = [
(
ActivationState::Healthy,
"ok",
"Shell hookup: dodot is sourced in new shells.",
),
(
ActivationState::VersionSkew,
"warning",
"Shell hookup: your shells load a different dodot.",
),
(
ActivationState::EmptyScript,
"info",
"Shell hookup: wired, but no packs are deployed.",
),
(
ActivationState::StaleShell,
"info",
"Shell hookup: this shell predates your last `dodot up`.",
),
(
ActivationState::NeverActivated,
"warning",
"Shell hookup: no shell has loaded dodot yet.",
),
(
ActivationState::ShellNotLoaded,
"info",
"Shell hookup: this shell did not load dodot.",
),
(
ActivationState::VerifiedBroken,
"error",
"Shell hookup: a new shell did not load dodot.",
),
];
for (state, severity, message) in cases {
let notice = ActivationNotice::for_state(state, "HOOK", None, "Never loaded.".into());
assert_eq!(notice.state, state.as_str());
assert_eq!(notice.severity, severity, "{state:?}");
assert_eq!(notice.message, message, "{state:?}");
assert_eq!(notice.evidence, "Never loaded.", "{state:?}");
}
}
#[test]
fn the_healthy_footer_reads_as_the_spec_tabulates_it() {
let evidence = Evidence {
stamp: EnvStamp {
generation: Some(100),
version: Some("5.6.0".into()),
},
..skewless()
};
let notice = evidence.footer("HOOK", None);
assert_eq!(
notice.message,
"Shell hookup: dodot is sourced in new shells."
);
assert_eq!(notice.evidence, "Last loaded 4 minutes ago by dodot 5.6.0.");
assert_eq!(notice.hint, None);
}
#[test]
fn a_contribution_less_script_says_so_instead_of_claiming_health() {
let evidence = Evidence {
stamp: EnvStamp {
generation: Some(100),
version: Some("5.6.0".into()),
},
script_has_contributions: false,
..skewless()
};
assert_eq!(evidence.state(), ActivationState::EmptyScript);
let broken = Evidence {
stamp: EnvStamp::default(),
heartbeat: None,
script_has_contributions: false,
..skewless()
};
assert_eq!(broken.state(), ActivationState::NeverActivated);
}
#[test]
fn never_activated_is_prominent_and_names_the_manual_hook() {
let hook = hook_line(
Path::new("/home/u/.local/share/dodot/shell/dodot-init.sh"),
Path::new("/home/u"),
);
assert_eq!(
hook,
"[ -f \"$HOME/.local/share/dodot/shell/dodot-init.sh\" ] && . \"$HOME/.local/share/dodot/shell/dodot-init.sh\""
);
let notice = ActivationNotice::for_state(
ActivationState::NeverActivated,
&hook,
None,
"Never loaded.".into(),
);
assert_eq!(notice.severity, "warning");
assert!(notice.message.contains("no shell has loaded dodot yet"));
let hint = notice.hint.unwrap();
assert!(
hint.contains(&hook),
"hint should carry the hook line: {hint}"
);
assert!(hint.contains("dodot install --write"), "hint: {hint}");
}
#[test]
fn shell_not_loaded_lets_the_rc_scan_pick_the_next_step() {
let not_loaded = |scan| {
ActivationNotice::for_state(
ActivationState::ShellNotLoaded,
"HOOK",
scan,
"Never loaded.".into(),
)
};
let absent = not_loaded(Some((HookPresence::Absent, "~/.zshrc".into())));
assert_eq!(absent.state, "shell-not-loaded");
assert_eq!(absent.severity, "warning");
assert_eq!(
absent.message,
"Shell hookup: this shell did not load dodot."
);
let hint = absent.hint.unwrap();
assert!(hint.contains("~/.zshrc") && hint.contains("dodot install --write"));
assert!(!hint.contains("new shell"), "{hint}");
for presence in [HookPresence::ManagedBlock, HookPresence::Manual] {
let present = not_loaded(Some((presence, "~/.zshrc".into())));
assert_eq!(present.severity, "info");
let hint = present.hint.unwrap();
assert!(
hint.contains("new shell") && hint.contains("dodot up"),
"{hint}"
);
}
let unknown = not_loaded(None);
assert_eq!(unknown.severity, "info");
assert!(unknown.hint.unwrap().contains("HOOK"));
}
#[test]
fn stale_shell_says_open_a_new_shell() {
let notice = ActivationNotice::for_state(
ActivationState::StaleShell,
"hook",
None,
"Never loaded.".into(),
);
assert_eq!(notice.severity, "info");
assert!(notice.hint.unwrap().contains("Open a new shell"));
}
#[test]
fn hook_line_outside_home_stays_absolute() {
let hook = hook_line(
Path::new("/opt/dodot/shell/dodot-init.sh"),
Path::new("/home/u"),
);
assert!(hook.contains("/opt/dodot/shell/dodot-init.sh"), "{hook}");
assert!(!hook.contains("$HOME"), "{hook}");
}
}