let _ = fs::set_permissions(d, fs::Permissions::from_mode(0o700));
}
Ok(())
}
fn state_path() -> io::Result<PathBuf> {
let d = state_dir()?;
ensure_state_dir_secure(&d)?;
Ok(d.join("window_stacks.json"))
}
fn backup_path(state_file: &Path) -> PathBuf {
// window_stacks.json.bak
let mut s = state_file.as_os_str().to_os_string();
s.push(".bak");
PathBuf::from(s)
}
fn lock_path() -> io::Result<PathBuf> {
let d = state_dir()?;
ensure_state_dir_secure(&d)?;
Ok(d.join("window_stacks.lock"))
}
fn derive_window_key(explicit: Option<String>) -> String {
// CLI override wins
if let Some(k) = explicit {
if !k.trim().is_empty() {
return k;
}
}
// Env override next (recommended)
if let Ok(v) = env::var("DIRSTACK_WINDOW_KEY") {
let v = v.trim();
if !v.is_empty() {
return v.to_string();
}
}
let candidates = [
"TMUX_PANE",
"WINDOWID",
"WEZTERM_PANE",
"KITTY_WINDOW_ID",
"TERM_SESSION_ID",
"SSH_TTY",
"TTY",
];
for &k in &candidates {
if let Ok(v) = env::var(k) {
let v = v.trim();
if !v.is_empty() {
return format!("{k}:{v}");
}
}
}
"default".to_string()
}
fn parse_args() -> Result<(Option<String>, String, Vec<String>), String> {
let mut args: Vec<OsString> = env::args_os().collect();
args.remove(0);
let mut window_override: Option<String> = None;
let mut i = 0usize;
while i < args.len() {
let a = args[i].to_string_lossy().to_string();
if a == "--window" {
if i + 1 >= args.len() {
return Err("missing value for --window".to_string());
}
let v = args[i + 1].to_string_lossy().to_string();
window_override = Some(v);
args.drain(i..=i + 1);
continue;
}
if a == "-h" || a == "--help" {
return Err("__HELP__".to_string());
}
i += 1;
}
if args.is_empty() {
return Err("__HELP__".to_string());
}
let cmd = args[0].to_string_lossy().to_string();
let rest = args[1..]
.iter()
.map(|x| x.to_string_lossy().to_string())
.collect();
Ok((window_override, cmd, rest))
}
fn normalize_dir(input: Option<&str>) -> io::Result<String> {
let p = match input {
Some(s) => PathBuf::from(s),
None => env::current_dir()?,
};
let canon = fs::canonicalize(&p).map_err(|_| {
io::Error::new(
io::ErrorKind::NotFound,
format!("directory not found: {}", p.display()),
)
})?;
let md = fs::metadata(&canon)?;
if !md.is_dir() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("not a directory: {}", canon.display()),
));
}
Ok(canon.to_string_lossy().to_string())
}
fn ensure_window<'a>(st: &'a mut AppState, key: &str) -> &'a mut WindowState {
st.windows.entry(key.to_string()).or_insert_with(|| WindowState {
stack: Vec::new(),
touched_unix: now_unix(),
})
}
fn push_dir(ws: &mut WindowState, dir: String) {
ws.stack.retain(|x| x != &dir);
ws.stack.insert(0, dir);
if ws.stack.len() > MAX_STACK {
ws.stack.truncate(MAX_STACK);
}
ws.touched_unix = now_unix();
}
fn pop_dir(ws: &mut WindowState) -> Option<String> {
if ws.stack.is_empty() {
return None;
}
ws.stack.remove(0);
ws.touched_unix = now_unix();
ws.stack.get(0).cloned()
}
fn peek_dir(ws: &WindowState) -> Option<String> {
ws.stack.get(0).cloned()
}
fn list_dirs(ws: &WindowState) {
for d in &ws.stack {
println!("{d}");
}
}
fn clear_dirs(ws: &mut WindowState) {
ws.stack.clear();
ws.touched_unix = now_unix();
}
fn gc_old(st: &mut AppState, days: i64) -> usize {
let cutoff = now_unix() - days.saturating_mul(24 * 3600);
let before = st.windows.len();
st.windows.retain(|_, ws| ws.touched_unix >= cutoff);
before - st.windows.len()
}
fn load_state(path: &Path) -> io::Result<AppState> {
match File::open(path) {
Ok(mut f) => {
let mut s = String::new();
f.read_to_string(&mut s)?;
if s.trim().is_empty() {
return Ok(AppState::default());
}
let st: AppState = serde_json::from_str(&s).map_err(|e| {
io::Error::new(io::ErrorKind::InvalidData, format!("invalid JSON state: {e}"))
})?;
if st.version != STATE_VERSION {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unsupported state version {}", st.version),
));
}
Ok(st)
}
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(AppState::default()),
Err(e) => Err(e),
}
}
fn is_lock_stale(lp: &Path) -> io::Result<bool> {
let meta = fs::metadata(lp)?;
let mtime = meta.modified()?;
let age = SystemTime::now()
.duration_since(mtime)
.unwrap_or(Duration::from_secs(0));
Ok(age.as_secs() > LOCK_STALE_SECS)
}
struct LockGuard {
path: PathBuf,
}
impl Drop for LockGuard {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
fn try_create_lock(lp: &Path) -> io::Result<LockGuard> {
#[cfg(unix)]
let mut f = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(lp)?;
#[cfg(not(unix))]
let mut f = OpenOptions::new().write(true).create_new(true).open(lp)?;
let pid = std::process::id();
let ts = now_unix();
writeln!(f, "pid={pid}\nts={ts}")?;
Ok(LockGuard { path: lp.to_path_buf() })
}
fn acquire_lock_with_retry() -> io::Result<LockGuard> {
let lp = lock_path()?;
let mut delay_ms = LOCK_RETRY_INITIAL_MS;
for attempt in 0..LOCK_RETRY_MAX_ATTEMPTS {
match try_create_lock(&lp) {
Ok(g) => return Ok(g),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
// Conservative stale handling: only if old enough.
if attempt + 1 == LOCK_RETRY_MAX_ATTEMPTS {
// Last attempt: consider stale
if is_lock_stale(&lp).unwrap_or(false) {
let _ = fs::remove_file(&lp);
// one final try after removal
return try_create_lock(&lp);
}
return Err(io::Error::new(
io::ErrorKind::WouldBlock,
"state is locked by another process",
));
}
sleep(Duration::from_millis(delay_ms));
delay_ms = (delay_ms * 2).min(LOCK_RETRY_MAX_MS);
}
Err(e) => return Err(e),
}
}
Err(io::Error::new(
io::ErrorKind::WouldBlock,
"state is locked by another process",
))
}
fn best_effort_backup(state_file: &Path) {
let bak = backup_path(state_file);
if state_file.exists() {
// Copy to .bak; ignore failures (best effort).
let _ = fs::copy(state_file, bak);
}
}
#[cfg(unix)]
fn fsync_dir(dir: &Path) -> io::Result<()> {
// Open directory and sync it to ensure rename durability.
let f = File::open(dir)?;
f.sync_all()
}
#[cfg(not(unix))]
fn fsync_dir(_dir: &Path) -> io::Result<()> {
// No-op on non-Unix in this hardened baseline.
Ok(())
}
fn atomic_write_durable(state_file: &Path, bytes: &[u8]) -> io::Result<()> {
let dir = state_file
.parent()
.ok_or_else(|| io::Error::new(io::ErrorKind::Other, "no parent dir"))?;
// Best-effort backup of current state
best_effort_backup(state_file);
// Temp file in same directory (required for atomic rename).
let tmp_name = format!(".window_stacks.json.tmp.{}", now_unix());
let tmp_path = dir.join(tmp_name);
#[cfg(unix)]
let mut f = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp_path)?;
#[cfg(not(unix))]
let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp_path)?;
f.write_all(bytes)?;
f.sync_all()?; // fsync temp file contents
// Atomic replace (Unix). If Windows rename semantics bite, this may need a
// platform-specific replace strategy.
fs::rename(&tmp_path, state_file)?;
// Ensure directory entry is durable (Unix)
fsync_dir(dir)?;
Ok(())
}
fn main() {
if let Err(e) = real_main() {
if e == "__HELP__" {
print!("{}", usage());
std::process::exit(0);
}
eprintln_err(&e);
std::process::exit(2);
}
}
fn real_main() -> Result<(), String> {
let (window_override, cmd, rest) = parse_args()?;
let window_key = derive_window_key(window_override);
let sp = state_path().map_err(|e| e.to_string())?;
let _lock = acquire_lock_with_retry().map_err(|e| {
if e.kind() == io::ErrorKind::WouldBlock {
"could not acquire lock (another process is using the state)".to_string()
} else {
format!("lock error: {e}")
}
})?;
let mut st = load_state(&sp).map_err(|e| e.to_string())?;
match cmd.as_str() {
"push" => {
let dir = if rest.is_empty() {
normalize_dir(None).map_err(|e| e.to_string())?
} else if rest.len() == 1 {
normalize_dir(Some(&rest[0])).map_err(|e| e.to_string())?
} else {
return Err("push takes at most one argument".to_string());
};
let ws = ensure_window(&mut st, &window_key);
push_dir(ws, dir);
let top = peek_dir(ws).unwrap_or_default();
let bytes = serde_json::to_vec_pretty(&st).map_err(|e| e.to_string())?;
atomic_write_durable(&sp, &bytes).map_err(|e| e.to_string())?;
if !top.is_empty() {
println!("{top}");
}
}
"pop" => {
let ws = ensure_window(&mut st, &window_key);
let new_top = pop_dir(ws);
let bytes = serde_json::to_vec_pretty(&st).map_err(|e| e.to_string())?;
atomic_write_durable(&sp, &bytes).map_err(|e| e.to_string())?;
if let Some(t) = new_top {
println!("{t}");
}
}
"peek" => {
let ws = ensure_window(&mut st, &window_key);
if let Some(t) = peek_dir(ws) {
println!("{t}");
}
}
"list" => {
let ws = ensure_window(&mut st, &window_key);
list_dirs(ws);
}
"clear" => {
let ws = ensure_window(&mut st, &window_key);
clear_dirs(ws);
let bytes = serde_json::to_vec_pretty(&st).map_err(|e| e.to_string())?;
atomic_write_durable(&sp, &bytes).map_err(|e| e.to_string())?;
}
"gc" => {
let mut days = DEFAULT_GC_DAYS;
if !rest.is_empty() {
if rest.len() == 2 && rest[0] == "--days" {
days = rest[1]
.parse::<i64>()
.map_err(|_| "invalid --days value".to_string())?;
if days < 1 {
return Err("--days must be >= 1".to_string());
}
} else {
return Err("gc syntax: gc [--days N]".to_string());
}
}
let removed = gc_old(&mut st, days);
let bytes = serde_json::to_vec_pretty(&st).map_err(|e| e.to_string())?;
atomic_write_durable(&sp, &bytes).map_err(|e| e.to_string())?;
eprintln!("dirstack: gc removed {removed} window stacks older than {days} day(s)");
}
_ => return Err("__HELP__".to_string()),
}
Ok(())
}
userland@localhost:~/dirstack$ cduserland@localhost:~$ nano histwin_setup.sh
userland@localhost:~$ chmod u+x histwin_setup.sh
userland@localhost:~$ ./histwin_setup.sh
==> Creating directories
==> Writing Cargo.toml (idempotent)
==> Writing src/main.rs (idempotent)
==> Writing default config to /home/userland/.config/histwin/windows.toml
==> Building (cargo build --release)
Locking 17 packages to latest compatible versions
Adding toml v0.8.23 (available: v1.0.1+spec-1.1.0)
Downloaded toml_write v0.1.2
Downloaded toml_datetime v0.6.11
Downloaded toml v0.8.23
Downloaded serde_spanned v0.6.9
Downloaded toml_edit v0.22.27
Downloaded winnow v0.7.14
Downloaded 6 crates (333.4KiB) in 1.90s
Compiling proc-macro2 v1.0.106
Compiling quote v1.0.44
Compiling unicode-ident v1.0.23
Compiling serde_core v1.0.228
Compiling serde v1.0.228
Compiling hashbrown v0.16.1
Compiling equivalent v1.0.2
Compiling toml_write v0.1.2
Compiling indexmap v2.13.0
Compiling winnow v0.7.14
Compiling syn v2.0.115
Compiling serde_derive v1.0.228
Compiling toml_datetime v0.6.11
Compiling serde_spanned v0.6.9
Compiling toml_edit v0.22.27
Compiling toml v0.8.23
Compiling histwin v1.0.0 (/home/userland/histwin)
Finished `release` profile [optimized] target(s) in 28.83s
==> Running: /home/userland/histwin/target/release/histwin
==> Done.
==> Histories will appear in: /home/userland/histfiles
userland@localhost:~$ cat ./histwin_setup.sh
#!/usr/bin/env bash
# ==============================================================================
# histwin_setup.sh — Idempotent setup + build + run for the hardened Rust
# per-window HISTFILE launcher (TOML-based).
#
# Creates:
# ~/histwin/ (Cargo project)
# ~/.config/histwin/ (config)
# ~/histfiles/ (history files directory)
#
# Then builds and runs:
# ~/histwin/target/release/histwin
#
# Requirements:
# - Linux/Unix-ish system
# - rust + cargo installed (rustc, cargo in PATH)
# - xterm installed (or change config to your terminal)
# ==============================================================================
set -euo pipefail
say() { printf '%s\n' "==> $*"; }
die() { printf '%s\n' "ERROR: $*" >&2; exit 1; }
need_cmd() { command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"; }
need_cmd cargo
need_cmd rustc
HOME_DIR="${HOME:?HOME not set}"
PROJ_DIR="$HOME_DIR/histwin"
SRC_DIR="$PROJ_DIR/src"
CFG_DIR="$HOME_DIR/.config/histwin"
CFG_FILE="$CFG_DIR/windows.toml"
HIST_DIR="$HOME_DIR/histfiles"
say "Creating directories"
mkdir -p "$SRC_DIR" "$CFG_DIR" "$HIST_DIR"
say "Writing Cargo.toml (idempotent)"
cat > "$PROJ_DIR/Cargo.toml" <<'TOML'