use std::path::Path;
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 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()
}
pub fn read_env_stamp() -> Option<u64> {
std::env::var(INIT_GEN_ENV)
.ok()
.as_deref()
.and_then(parse_generation)
}
pub fn read_heartbeat(fs: &dyn Fs, paths: &dyn Pather) -> Option<u64> {
let path = paths.hookup_heartbeat_path();
if !fs.exists(&path) {
return None;
}
fs.read_to_string(&path)
.ok()
.as_deref()
.and_then(parse_generation)
}
pub fn read_script_generation(fs: &dyn Fs, paths: &dyn Pather) -> Option<u64> {
let path = paths.init_script_path();
if !fs.exists(&path) {
return None;
}
let content = fs.read_to_string(&path).ok()?;
parse_script_generation(&content)
}
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)
}
#[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,
StaleShell,
NeverActivated,
ShellNotLoaded,
VerifiedBroken,
}
impl ActivationState {
pub fn as_str(self) -> &'static str {
match self {
ActivationState::Healthy => "healthy",
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 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,
}
}
pub fn collect_signals(
fs: &dyn Fs,
paths: &dyn Pather,
env_stamp: Option<u64>,
reference: Option<u64>,
tty: bool,
) -> ActivationState {
let heartbeat = read_heartbeat(fs, paths);
evaluate(
classify_stamp(env_stamp, reference),
classify_heartbeat(heartbeat, reference),
tty,
)
}
pub fn notice_for(
fs: &dyn Fs,
paths: &dyn Pather,
env_stamp: Option<u64>,
reference: Option<u64>,
quiet_ok: bool,
tty: bool,
shell_env: &rc::ShellEnv,
) -> Option<ActivationNotice> {
let init_script = paths.init_script_path();
if !fs.exists(&init_script) {
return None;
}
let state = collect_signals(fs, paths, env_stamp, reference, tty);
let hook_line = hook_line(&init_script, paths.home_dir());
if state == ActivationState::ShellNotLoaded {
let scan = rc::scan_expected_rc(fs, paths.home_dir(), shell_env, None);
return Some(ActivationNotice::for_shell_not_loaded(scan, &hook_line));
}
ActivationNotice::for_state(state, &hook_line, quiet_ok)
}
#[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>,
}
impl ActivationNotice {
pub fn for_state(
state: ActivationState,
hook_line: &str,
quiet_ok: bool,
) -> Option<ActivationNotice> {
match state {
ActivationState::Healthy if !quiet_ok => None,
ActivationState::Healthy => Some(ActivationNotice {
state: state.as_str().into(),
severity: "ok".into(),
message: "shell hookup: ok".into(),
hint: None,
}),
ActivationState::StaleShell => Some(ActivationNotice {
state: state.as_str().into(),
severity: "info".into(),
message: "This shell started before your last `dodot up`.".into(),
hint: Some("Open a new shell to pick up the current deployment.".into()),
}),
ActivationState::NeverActivated => Some(ActivationNotice {
state: state.as_str().into(),
severity: "warning".into(),
message: "Deployed, but no shell has loaded dodot yet.".into(),
hint: Some(format!(
"Run `dodot install --write` to wire it up, or add this to your shell rc \
file yourself: {hook_line}"
)),
}),
ActivationState::ShellNotLoaded => {
Some(ActivationNotice::for_shell_not_loaded(None, hook_line))
}
ActivationState::VerifiedBroken => Some(ActivationNotice {
state: state.as_str().into(),
severity: "error".into(),
message: VERIFIED_BROKEN_MESSAGE.into(),
hint: Some(format!(
"Run `dodot install --write` to wire the hook, or add this to your shell rc \
file yourself: {hook_line}"
)),
}),
}
}
pub fn for_shell_not_loaded(
scan: Option<(HookPresence, String)>,
hook_line: &str,
) -> ActivationNotice {
let state = ActivationState::ShellNotLoaded;
let (severity, hint) = match scan {
Some((HookPresence::Absent, rc_path)) => (
"warning",
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)) => (
"info",
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 => (
"info",
format!(
"dodot could not tell which rc file your shell reads. Make sure it has \
this line: {hook_line}"
),
),
};
ActivationNotice {
state: state.as_str().into(),
severity: severity.into(),
message: "This shell hasn't loaded dodot.".into(),
hint: Some(hint),
}
}
}
pub const VERIFIED_BROKEN_MESSAGE: &str = "Deployed, but a new shell did not load dodot.";
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;
#[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_collect_signals() {
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();
if let Some(h) = heartbeat {
env.fs.mkdir_all(&env.paths.probes_hookup_dir()).unwrap();
env.fs
.write_file(&env.paths.hookup_heartbeat_path(), h.to_string().as_bytes())
.unwrap();
}
assert_eq!(
collect_signals(env.fs.as_ref(), env.paths.as_ref(), stamp, Some(100), tty),
expected,
"stamp={stamp:?} heartbeat={heartbeat:?} tty={tty}"
);
}
}
#[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();
env.fs.mkdir_all(&env.paths.probes_hookup_dir()).unwrap();
env.fs
.write_file(&env.paths.hookup_heartbeat_path(), b"garbage")
.unwrap();
assert_eq!(read_heartbeat(env.fs.as_ref(), env.paths.as_ref()), 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 healthy_is_silent_for_up_and_quiet_for_status() {
assert_eq!(
ActivationNotice::for_state(ActivationState::Healthy, "hook", false),
None
);
let quiet = ActivationNotice::for_state(ActivationState::Healthy, "hook", true).unwrap();
assert_eq!(quiet.severity, "ok");
assert_eq!(quiet.state, "healthy");
}
#[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, true).unwrap();
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 absent = ActivationNotice::for_shell_not_loaded(
Some((HookPresence::Absent, "~/.zshrc".into())),
"HOOK",
);
assert_eq!(absent.state, "shell-not-loaded");
assert_eq!(absent.severity, "warning");
assert_eq!(absent.message, "This shell hasn't loaded 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 =
ActivationNotice::for_shell_not_loaded(Some((presence, "~/.zshrc".into())), "HOOK");
assert_eq!(present.severity, "info");
let hint = present.hint.unwrap();
assert!(
hint.contains("new shell") && hint.contains("dodot up"),
"{hint}"
);
}
let unknown = ActivationNotice::for_shell_not_loaded(None, "HOOK");
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", true).unwrap();
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}");
}
}