use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
pub const CURRENT_VERSION: u32 = 1;
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AgentPrefs {
pub version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
#[serde(default)]
pub skills: SkillsPrefs,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_project: Option<DefaultProject>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
}
impl Default for AgentPrefs {
fn default() -> Self {
Self {
version: CURRENT_VERSION,
agent: None,
skills: SkillsPrefs::default(),
default_project: None,
theme: None,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DefaultProject {
pub project_id: String,
pub project_name: String,
pub environment_id: String,
pub environment_name: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct SkillsPrefs {
#[serde(default)]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exclude: Vec<String>,
}
impl AgentPrefs {
pub fn path_in(home: &Path) -> PathBuf {
home.join(".railway").join("agent-prefs.json")
}
pub fn load_in(home: &Path) -> Option<Self> {
let raw = std::fs::read_to_string(Self::path_in(home)).ok()?;
serde_json::from_str(&raw).ok()
}
pub fn save_in(&self, home: &Path) -> Result<()> {
let path = Self::path_in(home);
let contents = serde_json::to_string_pretty(self)?;
crate::util::write_atomic(&path, &contents)
.with_context(|| format!("Failed to write {}", path.display()))?;
restrict_permissions(&path);
Ok(())
}
}
fn restrict_permissions(path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
#[cfg(not(unix))]
{
let _ = path;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_through_disk() {
let home = tempfile::tempdir().unwrap();
let prefs = AgentPrefs {
version: CURRENT_VERSION,
agent: Some("claude".into()),
skills: SkillsPrefs {
enabled: true,
source: Some("claude".into()),
exclude: vec!["use-railway".into()],
},
default_project: None,
theme: Some("ember".into()),
};
prefs.save_in(home.path()).unwrap();
assert_eq!(AgentPrefs::load_in(home.path()).unwrap(), prefs);
}
#[test]
fn missing_file_is_not_configured() {
let home = tempfile::tempdir().unwrap();
assert!(AgentPrefs::load_in(home.path()).is_none());
}
#[test]
fn corrupt_file_degrades_to_not_configured() {
let home = tempfile::tempdir().unwrap();
let path = AgentPrefs::path_in(home.path());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "{ not json").unwrap();
assert!(AgentPrefs::load_in(home.path()).is_none());
}
#[test]
fn unknown_fields_and_newer_version_still_parse() {
let home = tempfile::tempdir().unwrap();
let path = AgentPrefs::path_in(home.path());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
r#"{"version": 99, "agent": "codex", "somethingNew": {"a": 1}}"#,
)
.unwrap();
let prefs = AgentPrefs::load_in(home.path()).unwrap();
assert_eq!(prefs.agent.as_deref(), Some("codex"));
assert!(!prefs.skills.enabled);
}
#[test]
fn agent_field_is_serialized_as_a_slug() {
let prefs = AgentPrefs {
agent: Some("grok".into()),
..Default::default()
};
let json = serde_json::to_string(&prefs).unwrap();
assert!(json.contains(r#""agent":"grok""#), "{json}");
}
}