use crate::paths;
use anyhow::{Context, Result, anyhow};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Meta {
pub id: String,
pub cmd: Vec<String>,
pub babysit_pid: u32,
pub started_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Status {
pub state: State,
pub child_pid: Option<u32>,
pub exit_code: Option<i32>,
pub last_change: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum State {
Starting,
Running,
Exited,
Killed,
}
impl State {
pub fn is_terminal(self) -> bool {
matches!(self, State::Exited | State::Killed)
}
}
impl Status {
pub fn starting() -> Self {
Self {
state: State::Starting,
child_pid: None,
exit_code: None,
last_change: Utc::now(),
}
}
}
pub fn is_pid_alive(pid: u32) -> bool {
use nix::errno::Errno;
use nix::sys::signal::kill;
use nix::unistd::Pid;
matches!(
kill(Pid::from_raw(pid as i32), None),
Ok(_) | Err(Errno::EPERM)
)
}
pub async fn make_id(requested: Option<String>) -> Result<String> {
match requested {
Some(id) => {
validate_id(&id)?;
let dir = paths::session_dir(&id)?;
if tokio::fs::try_exists(&dir).await.unwrap_or(false) {
return Err(anyhow!(
"session `{id}` already exists; pick another --id or run `babysit prune`"
));
}
Ok(id)
}
None => Ok(new_unique_id().await),
}
}
fn validate_id(id: &str) -> Result<()> {
if id.is_empty() {
return Err(anyhow!("session id must not be empty"));
}
if id.len() > 64 {
return Err(anyhow!("session id too long (max 64 characters)"));
}
if id == "latest" {
return Err(anyhow!(
"`latest` is reserved and can't be used as a session id"
));
}
if id == "." || id == ".." {
return Err(anyhow!("`.` and `..` are not valid session ids"));
}
if !id
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
{
return Err(anyhow!(
"session id may only contain ASCII letters, digits, `-`, `_`, `.`"
));
}
Ok(())
}
pub fn new_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let pid = std::process::id() as u64;
let mix = nanos.wrapping_mul(2862933555777941757).wrapping_add(pid);
format!("{:04x}", (mix as u16))
}
pub async fn new_unique_id() -> String {
for _ in 0..10_000 {
let id = new_id();
match paths::session_dir(&id) {
Ok(dir) if tokio::fs::try_exists(&dir).await.unwrap_or(false) => continue,
_ => return id,
}
}
new_id()
}
pub async fn write_meta(meta: &Meta) -> Result<()> {
let dir = paths::session_dir(&meta.id)?;
tokio::fs::create_dir_all(&dir).await?;
let path = paths::meta_path(&meta.id)?;
let json = serde_json::to_vec_pretty(meta)?;
tokio::fs::write(&path, json).await?;
Ok(())
}
pub async fn write_status(id: &str, status: &Status) -> Result<()> {
let path = paths::status_path(id)?;
let json = serde_json::to_vec_pretty(status)?;
let tmp = path.with_extension("json.tmp");
tokio::fs::write(&tmp, json).await?;
tokio::fs::rename(&tmp, &path).await?;
Ok(())
}
pub async fn read_meta(id: &str) -> Result<Meta> {
let path = paths::meta_path(id)?;
let bytes = tokio::fs::read(&path)
.await
.with_context(|| format!("reading meta for {id}"))?;
Ok(serde_json::from_slice(&bytes)?)
}
pub async fn read_status(id: &str) -> Result<Status> {
let path = paths::status_path(id)?;
let bytes = tokio::fs::read(&path)
.await
.with_context(|| format!("reading status for {id}"))?;
Ok(serde_json::from_slice(&bytes)?)
}
pub async fn write_note(id: &str, message: &str) -> Result<()> {
let dir = paths::session_dir(id)?;
tokio::fs::create_dir_all(&dir).await?;
tokio::fs::write(paths::note_path(id)?, message.as_bytes()).await?;
Ok(())
}
pub async fn clear_note(id: &str) -> Result<()> {
match tokio::fs::remove_file(paths::note_path(id)?).await {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
pub async fn read_note(id: &str) -> Option<String> {
let path = paths::note_path(id).ok()?;
let bytes = tokio::fs::read(&path).await.ok()?;
Some(String::from_utf8_lossy(&bytes).trim().to_string())
}
pub async fn list_ids() -> Result<Vec<String>> {
let dir = paths::sessions_dir()?;
if !tokio::fs::try_exists(&dir).await.unwrap_or(false) {
return Ok(Vec::new());
}
let mut rd = tokio::fs::read_dir(&dir).await?;
let mut ids = Vec::new();
while let Some(entry) = rd.next_entry().await? {
if entry.file_type().await?.is_dir()
&& let Some(name) = entry.file_name().to_str()
{
ids.push(name.to_string());
}
}
Ok(ids)
}
pub async fn resolve(session: Option<String>) -> Result<String> {
if let Some(s) = session {
return resolve_one(&s).await;
}
if let Ok(env_id) = std::env::var("BABYSIT_SESSION_ID")
&& !env_id.is_empty()
{
return resolve_one(&env_id).await;
}
resolve_latest().await
}
async fn resolve_one(s: &str) -> Result<String> {
if s == "latest" {
return resolve_latest().await;
}
let ids = list_ids().await?;
if ids.iter().any(|i| i == s) {
return Ok(s.to_string());
}
Err(anyhow!("no session matching `{s}`"))
}
async fn resolve_latest() -> Result<String> {
let ids = list_ids().await?;
if ids.is_empty() {
return Err(anyhow!("no sessions found"));
}
let mut best: Option<(String, std::time::SystemTime)> = None;
for id in ids {
let path = paths::status_path(&id)?;
if let Ok(meta) = tokio::fs::metadata(&path).await {
let modified = meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH);
if best.as_ref().map(|(_, t)| modified > *t).unwrap_or(true) {
best = Some((id.clone(), modified));
}
}
}
best.map(|(id, _)| id)
.ok_or_else(|| anyhow!("no sessions with status"))
}