rns-cli 0.2.2

CLI tools for the Reticulum Network Stack
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

pub struct ReadyFile {
    path: PathBuf,
}

impl ReadyFile {
    pub fn new(path: Option<&str>) -> Result<Option<Self>, String> {
        let Some(path) = path else {
            return Ok(None);
        };
        let ready_file = Self {
            path: PathBuf::from(path),
        };
        ready_file.clear()?;
        Ok(Some(ready_file))
    }

    pub fn mark_ready(&self, process: &str, detail: &str) -> Result<(), String> {
        self.write_status(process, "ready", detail)
    }

    pub fn mark_draining(&self, process: &str, detail: &str) -> Result<(), String> {
        self.write_status(process, "draining", detail)
    }

    fn write_status(&self, process: &str, status: &str, detail: &str) -> Result<(), String> {
        if let Some(parent) = self.path.parent() {
            fs::create_dir_all(parent).map_err(|err| {
                format!(
                    "failed to create readiness dir {}: {}",
                    parent.display(),
                    err
                )
            })?;
        }

        let body = format!(
            "version=1\nstatus={}\nprocess={}\npid={}\ntimestamp_ms={}\ndetail={}\n",
            status,
            process,
            std::process::id(),
            now_unix_ms(),
            escape_value(detail),
        );
        fs::write(&self.path, body).map_err(|err| {
            format!(
                "failed to write readiness file {}: {}",
                self.path.display(),
                err
            )
        })
    }

    pub fn clear(&self) -> Result<(), String> {
        match fs::remove_file(&self.path) {
            Ok(()) => Ok(()),
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
            Err(err) => Err(format!(
                "failed to remove readiness file {}: {}",
                self.path.display(),
                err
            )),
        }
    }

    pub fn path(&self) -> &Path {
        &self.path
    }
}

impl Drop for ReadyFile {
    fn drop(&mut self) {
        let _ = self.clear();
    }
}

fn now_unix_ms() -> u128 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis()
}

fn escape_value(value: &str) -> String {
    value.replace('\\', "\\\\").replace('\n', "\\n")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn ready_file_lifecycle_writes_and_clears_contract_file() {
        let tempdir = tempfile::tempdir().unwrap();
        let path = tempdir.path().join("sentineld.ready");
        let path_string = path.display().to_string();
        let ready_file = ReadyFile::new(Some(&path_string))
            .unwrap()
            .expect("ready file should be configured");

        ready_file
            .mark_ready(
                "rns-sentineld",
                "hooks loaded and provider bridge connected",
            )
            .unwrap();

        let body = fs::read_to_string(ready_file.path()).unwrap();
        assert!(body.contains("version=1"));
        assert!(body.contains("status=ready"));
        assert!(body.contains("process=rns-sentineld"));
        assert!(body.contains("detail=hooks loaded and provider bridge connected"));

        ready_file.clear().unwrap();
        assert!(!ready_file.path().exists());
    }

    #[test]
    fn ready_file_can_report_draining_state() {
        let tempdir = tempfile::tempdir().unwrap();
        let path = tempdir.path().join("statsd.ready");
        let path_string = path.display().to_string();
        let ready_file = ReadyFile::new(Some(&path_string))
            .unwrap()
            .expect("ready file should be configured");

        ready_file
            .mark_draining("rns-statsd", "stopping ingest and flushing stats database")
            .unwrap();

        let body = fs::read_to_string(ready_file.path()).unwrap();
        assert!(body.contains("status=draining"));
        assert!(body.contains("process=rns-statsd"));
        assert!(body.contains("detail=stopping ingest and flushing stats database"));
    }

    #[test]
    fn ready_file_new_clears_stale_file() {
        let tempdir = tempfile::tempdir().unwrap();
        let path = tempdir.path().join("statsd.ready");
        fs::write(&path, "stale").unwrap();

        let path_string = path.display().to_string();
        let ready_file = ReadyFile::new(Some(&path_string))
            .unwrap()
            .expect("ready file should be configured");

        assert!(!ready_file.path().exists());
    }
}