use std::io::Write;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct State {
pub workflow_path: PathBuf,
pub cwd: PathBuf,
pub pid: u32,
pub port: u16,
pub bind_address: String,
pub started_at: DateTime<Utc>,
pub log_dir: PathBuf,
pub sessions_dir: PathBuf,
pub command: String,
}
#[derive(Debug, Error)]
pub enum StateError {
#[error("failed to read daemon state file {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse daemon state file {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error("failed to write daemon state file {path}: {source}")]
Write {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to serialize daemon state: {0}")]
Serialize(#[source] serde_json::Error),
#[error("failed to remove daemon state file {path}: {source}")]
Remove {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
impl State {
pub fn try_read(path: &Path) -> Result<Option<Self>, StateError> {
match std::fs::read(path) {
Ok(bytes) => {
let state: State = serde_json::from_slice(&bytes).map_err(|source| StateError::Parse {
path: path.to_path_buf(),
source,
})?;
Ok(Some(state))
},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(source) => Err(StateError::Read {
path: path.to_path_buf(),
source,
}),
}
}
pub fn write(&self, path: &Path) -> Result<(), StateError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|source| StateError::Write {
path: parent.to_path_buf(),
source,
})?;
}
let body = serde_json::to_vec_pretty(self).map_err(StateError::Serialize)?;
let mut tmp = path.to_path_buf();
let mut name = match path.file_name() {
Some(n) => n.to_os_string(),
None => {
return Err(StateError::Write {
path: path.to_path_buf(),
source: std::io::Error::other("state file path has no filename component"),
});
},
};
name.push(format!(".tmp.{}", std::process::id()));
tmp.set_file_name(name);
let mut file = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&tmp)
.map_err(|source| StateError::Write {
path: tmp.clone(),
source,
})?;
file.write_all(&body).map_err(|source| StateError::Write {
path: tmp.clone(),
source,
})?;
file.sync_all().map_err(|source| StateError::Write {
path: tmp.clone(),
source,
})?;
drop(file);
std::fs::rename(&tmp, path).map_err(|source| StateError::Write {
path: path.to_path_buf(),
source,
})?;
Ok(())
}
pub fn remove(path: &Path) -> Result<(), StateError> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(source) => Err(StateError::Remove {
path: path.to_path_buf(),
source,
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn sample(workflow: &Path, cwd: &Path) -> State {
State {
workflow_path: workflow.to_path_buf(),
cwd: cwd.to_path_buf(),
pid: 1234,
port: 3000,
bind_address: "127.0.0.1".into(),
started_at: "2026-05-09T10:00:00Z".parse().unwrap(),
log_dir: cwd.join("logs"),
sessions_dir: cwd.join("sessions"),
command: "vik run -d --port 3000 workflow.yml".into(),
}
}
#[test]
fn roundtrip_preserves_fields() {
let dir = TempDir::new().expect("tmpdir");
let path = dir.path().join("state.json");
let state = sample(&dir.path().join("workflow.yml"), dir.path());
state.write(&path).expect("write ok");
let back = State::try_read(&path).expect("read ok").expect("present");
assert_eq!(back, state);
}
#[test]
fn try_read_missing_returns_none() {
let dir = TempDir::new().expect("tmpdir");
let path = dir.path().join("state.json");
let res = State::try_read(&path).expect("missing is Ok(None)");
assert!(res.is_none());
}
#[test]
fn write_creates_parent_directory() {
let dir = TempDir::new().expect("tmpdir");
let path = dir.path().join("nested/a/b/state.json");
let state = sample(&dir.path().join("workflow.yml"), dir.path());
state.write(&path).expect("write ok");
assert!(path.exists());
}
#[test]
fn remove_missing_is_ok() {
let dir = TempDir::new().expect("tmpdir");
let path = dir.path().join("never-created.json");
State::remove(&path).expect("remove missing ok");
}
#[test]
fn remove_existing_deletes_file() {
let dir = TempDir::new().expect("tmpdir");
let path = dir.path().join("state.json");
let state = sample(&dir.path().join("workflow.yml"), dir.path());
state.write(&path).expect("write");
assert!(path.exists());
State::remove(&path).expect("remove");
assert!(!path.exists());
}
#[test]
fn try_read_errors_on_malformed_json() {
let dir = TempDir::new().expect("tmpdir");
let path = dir.path().join("state.json");
std::fs::write(&path, b"{not json").expect("seed");
let err = State::try_read(&path).expect_err("bad json must fail");
assert!(matches!(err, StateError::Parse { .. }));
}
}