use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
pub const DEFAULT_INSTANCE: &str = "default";
pub fn validate_instance_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("service name must not be empty".into());
}
let first = name.as_bytes()[0];
if !first.is_ascii_alphanumeric() {
return Err("service name must start with a letter or digit".into());
}
for b in name.bytes() {
let ok = b.is_ascii_alphanumeric() || b == b'-' || b == b'_';
if !ok {
return Err(format!(
"service name '{name}' contains invalid character {:?}; \
allowed: letters, digits, '-', '_'",
b as char
));
}
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Scope {
User,
System,
}
impl Scope {
pub fn as_str(self) -> &'static str {
match self {
Scope::User => "user",
Scope::System => "system",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstallRecord {
#[serde(default = "default_instance_name")]
pub instance: String,
pub scope: Scope,
pub unit_path: PathBuf,
pub config_path: PathBuf,
pub user_account: Option<String>,
pub parallel: u32,
#[serde(default)]
pub unit_or_label: String,
}
fn default_instance_name() -> String {
DEFAULT_INSTANCE.to_string()
}
fn root_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".anvil-runner")
}
fn services_dir() -> PathBuf {
root_dir().join("services")
}
pub fn record_path(instance: &str) -> PathBuf {
services_dir().join(format!("{instance}.json"))
}
fn legacy_path() -> PathBuf {
root_dir().join("service.json")
}
fn migrate_legacy_if_needed() {
let new_default = record_path(DEFAULT_INSTANCE);
if new_default.exists() {
return;
}
let legacy = legacy_path();
let Ok(contents) = fs::read_to_string(&legacy) else {
return;
};
let Ok(mut record) = serde_json::from_str::<InstallRecord>(&contents) else {
return;
};
if record.instance.is_empty() {
record.instance = DEFAULT_INSTANCE.to_string();
}
if record.unit_or_label.is_empty() {
if let Some(name) = record.unit_path.file_name().and_then(|s| s.to_str()) {
record.unit_or_label = if let Some(stem) = name.strip_suffix(".plist") {
stem.to_string()
} else {
name.to_string()
};
}
}
if let Some(parent) = new_default.parent() {
let _ = fs::create_dir_all(parent);
}
if let Ok(s) = serde_json::to_string_pretty(&record) {
let _ = fs::write(&new_default, s);
}
}
pub fn save(record: &InstallRecord) -> std::io::Result<()> {
let p = record_path(&record.instance);
if let Some(parent) = p.parent() {
fs::create_dir_all(parent)?;
}
let contents = serde_json::to_string_pretty(record).map_err(std::io::Error::other)?;
fs::write(p, contents)
}
pub fn load(instance: &str) -> Option<InstallRecord> {
migrate_legacy_if_needed();
let contents = fs::read_to_string(record_path(instance)).ok()?;
serde_json::from_str(&contents).ok()
}
pub fn list_instances() -> Vec<String> {
migrate_legacy_if_needed();
let dir = services_dir();
let Ok(entries) = fs::read_dir(&dir) else {
return Vec::new();
};
let mut names = Vec::new();
for entry in entries.flatten() {
let p = entry.path();
if p.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) {
names.push(stem.to_string());
}
}
names.sort();
names
}
pub fn clear(instance: &str) -> std::io::Result<()> {
let p = record_path(instance);
if p.exists() {
fs::remove_file(p)?;
}
if instance == DEFAULT_INSTANCE {
let legacy = legacy_path();
if legacy.exists() {
let _ = fs::remove_file(legacy);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_instance_name_accepts_typical() {
assert!(validate_instance_name("default").is_ok());
assert!(validate_instance_name("gpu1").is_ok());
assert!(validate_instance_name("ci-build_2").is_ok());
assert!(validate_instance_name("A").is_ok());
}
#[test]
fn validate_instance_name_rejects_invalid() {
assert!(validate_instance_name("").is_err());
assert!(validate_instance_name("-foo").is_err());
assert!(validate_instance_name("foo bar").is_err());
assert!(validate_instance_name("foo/bar").is_err());
assert!(validate_instance_name("foo.bar").is_err());
}
#[test]
fn scope_round_trips_through_json() {
let record = InstallRecord {
instance: "default".into(),
scope: Scope::System,
unit_path: "/etc/systemd/system/anvil-runner-default.service".into(),
config_path: "/home/ci/.anvil-runner/config.json".into(),
user_account: Some("ci-runner".into()),
parallel: 4,
unit_or_label: "anvil-runner-default.service".into(),
};
let json = serde_json::to_string(&record).unwrap();
let parsed: InstallRecord = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.scope, Scope::System);
assert_eq!(parsed.parallel, 4);
assert_eq!(parsed.user_account.as_deref(), Some("ci-runner"));
assert_eq!(parsed.instance, "default");
}
#[test]
fn scope_serializes_as_lowercase() {
let json = serde_json::to_string(&Scope::User).unwrap();
assert_eq!(json, "\"user\"");
}
#[test]
fn record_path_uses_services_subdir() {
let p = record_path("gpu1");
assert!(p.to_string_lossy().contains("/services/gpu1.json"));
}
#[test]
fn legacy_record_without_instance_field_loads_as_default() {
let legacy_json = r#"{
"scope": "user",
"unit_path": "/tmp/anvil-runner.service",
"config_path": "/tmp/config.json",
"user_account": null,
"parallel": 1
}"#;
let record: InstallRecord = serde_json::from_str(legacy_json).unwrap();
assert_eq!(record.instance, "default");
assert_eq!(record.parallel, 1);
}
}