1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
8pub struct AgentConfig {
9 pub agent_id: String,
10 pub machine_id: String,
11 #[serde(default)]
12 pub description: Option<String>,
13}
14
15impl AgentConfig {
16 pub fn load(chainlink_dir: &Path) -> Result<Option<Self>> {
18 let path = chainlink_dir.join("agent.json");
19 if !path.exists() {
20 return Ok(None);
21 }
22 let content = std::fs::read_to_string(&path)
23 .with_context(|| format!("Failed to read {}", path.display()))?;
24 let config: AgentConfig = serde_json::from_str(&content)
25 .with_context(|| format!("Failed to parse {}", path.display()))?;
26 config.validate()?;
27 Ok(Some(config))
28 }
29
30 pub fn init(chainlink_dir: &Path, agent_id: &str, description: Option<&str>) -> Result<Self> {
32 let machine_id = detect_hostname();
33 let config = AgentConfig {
34 agent_id: agent_id.to_string(),
35 machine_id,
36 description: description.map(|s| s.to_string()),
37 };
38 config.validate()?;
39 let path = chainlink_dir.join("agent.json");
40 let json = serde_json::to_string_pretty(&config)?;
41 std::fs::write(&path, json)
42 .with_context(|| format!("Failed to write {}", path.display()))?;
43 Ok(config)
44 }
45
46 fn validate(&self) -> Result<()> {
47 anyhow::ensure!(!self.agent_id.is_empty(), "agent_id cannot be empty");
48 anyhow::ensure!(
49 self.agent_id.len() >= 3,
50 "agent_id must be at least 3 characters"
51 );
52 anyhow::ensure!(
53 self.agent_id
54 .chars()
55 .all(|c| c.is_alphanumeric() || c == '-' || c == '_'),
56 "agent_id must be alphanumeric with hyphens/underscores only"
57 );
58 anyhow::ensure!(
59 self.agent_id.len() <= 64,
60 "agent_id must be <= 64 characters"
61 );
62 anyhow::ensure!(
63 !is_windows_reserved_name(&self.agent_id),
64 "agent_id '{}' is a Windows reserved filename",
65 self.agent_id
66 );
67 Ok(())
68 }
69}
70
71fn detect_hostname() -> String {
73 if let Ok(name) = std::env::var("COMPUTERNAME") {
74 return name;
75 }
76 if let Ok(name) = std::env::var("HOSTNAME") {
77 return name;
78 }
79 if let Ok(output) = std::process::Command::new("hostname").output() {
80 if output.status.success() {
81 let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
82 if !name.is_empty() {
83 return name;
84 }
85 }
86 }
87 "unknown".to_string()
88}
89
90fn is_windows_reserved_name(name: &str) -> bool {
92 let upper = name.to_uppercase();
93 matches!(
94 upper.as_str(),
95 "CON"
96 | "PRN"
97 | "AUX"
98 | "NUL"
99 | "COM1"
100 | "COM2"
101 | "COM3"
102 | "COM4"
103 | "COM5"
104 | "COM6"
105 | "COM7"
106 | "COM8"
107 | "COM9"
108 | "LPT1"
109 | "LPT2"
110 | "LPT3"
111 | "LPT4"
112 | "LPT5"
113 | "LPT6"
114 | "LPT7"
115 | "LPT8"
116 | "LPT9"
117 )
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use tempfile::tempdir;
124
125 fn test_config(agent_id: &str) -> AgentConfig {
126 AgentConfig {
127 agent_id: agent_id.to_string(),
128 machine_id: "test".to_string(),
129 description: None,
130 }
131 }
132
133 #[test]
134 fn test_load_missing_file() {
135 let dir = tempdir().unwrap();
136 let result = AgentConfig::load(dir.path()).unwrap();
137 assert!(result.is_none());
138 }
139
140 #[test]
141 fn test_init_and_load_roundtrip() {
142 let dir = tempdir().unwrap();
143 let config = AgentConfig::init(dir.path(), "worker-1", Some("Test agent")).unwrap();
144 assert_eq!(config.agent_id, "worker-1");
145 assert_eq!(config.description, Some("Test agent".to_string()));
146 assert!(!config.machine_id.is_empty());
147
148 let loaded = AgentConfig::load(dir.path()).unwrap().unwrap();
149 assert_eq!(loaded.agent_id, config.agent_id);
150 assert_eq!(loaded.machine_id, config.machine_id);
151 assert_eq!(loaded.description, config.description);
152 }
153
154 #[test]
155 fn test_validate_empty_id() {
156 assert!(test_config("").validate().is_err());
157 }
158
159 #[test]
160 fn test_validate_invalid_chars() {
161 assert!(test_config("worker 1").validate().is_err());
162 assert!(test_config("worker@1").validate().is_err());
163 }
164
165 #[test]
166 fn test_validate_too_short() {
167 assert!(test_config("ab").validate().is_err());
168 assert!(test_config("abc").validate().is_ok());
169 }
170
171 #[test]
172 fn test_validate_valid_ids() {
173 for id in &["worker-1", "agent_2", "MyAgent", "abc", "test-agent-42"] {
174 assert!(test_config(id).validate().is_ok(), "Failed for id: {}", id);
175 }
176 }
177
178 #[test]
179 fn test_validate_rejects_windows_reserved() {
180 for id in &["CON", "con", "PRN", "AUX", "NUL", "COM1", "LPT1"] {
181 assert!(test_config(id).validate().is_err(), "Should reject: {}", id);
182 }
183 }
184
185 #[test]
186 fn test_json_roundtrip() {
187 let config = AgentConfig {
188 description: Some("Test agent".to_string()),
189 machine_id: "my-host".to_string(),
190 ..test_config("worker-1")
191 };
192 let json = serde_json::to_string(&config).unwrap();
193 let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
194 assert_eq!(config, parsed);
195 }
196
197 #[test]
198 fn test_json_missing_description_defaults_none() {
199 let json = r#"{"agent_id": "worker-1", "machine_id": "host"}"#;
200 let config: AgentConfig = serde_json::from_str(json).unwrap();
201 assert!(config.description.is_none());
202 }
203
204 #[test]
205 fn test_detect_hostname_returns_something() {
206 let hostname = detect_hostname();
207 assert!(!hostname.is_empty());
208 }
209}