ralph_api/
config_domain.rs1use std::fs;
2use std::path::{Path, PathBuf};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use tracing::warn;
8
9use crate::errors::ApiError;
10
11#[derive(Debug, Clone, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct ConfigUpdateParams {
14 pub content: String,
15}
16
17#[derive(Debug, Clone, Serialize)]
18#[serde(rename_all = "camelCase")]
19pub struct ConfigGetResult {
20 pub raw: String,
21 pub parsed: serde_json::Map<String, Value>,
22}
23
24#[derive(Debug, Clone, Serialize)]
25#[serde(rename_all = "camelCase")]
26pub struct ConfigUpdateResult {
27 pub success: bool,
28 pub parsed: serde_json::Map<String, Value>,
29}
30
31#[derive(Debug, Clone)]
32pub struct ConfigDomain {
33 config_path: PathBuf,
34}
35
36impl ConfigDomain {
37 pub fn new(workspace_root: impl AsRef<Path>) -> Self {
38 Self {
39 config_path: workspace_root.as_ref().join("ralph.yml"),
40 }
41 }
42
43 pub fn get(&self) -> Result<ConfigGetResult, ApiError> {
44 if !self.config_path.exists() {
45 return Err(ApiError::not_found(
46 "configuration file not found at ralph.yml",
47 ));
48 }
49
50 let raw = fs::read_to_string(&self.config_path).map_err(|error| {
51 ApiError::internal(format!(
52 "failed reading config file '{}': {error}",
53 self.config_path.display()
54 ))
55 })?;
56
57 let parsed = parse_yaml_to_json_object(&raw).unwrap_or_else(|error| {
58 warn!(
59 path = %self.config_path.display(),
60 %error,
61 "failed parsing config yaml in config.get; returning empty object"
62 );
63 serde_json::Map::new()
64 });
65
66 Ok(ConfigGetResult { raw, parsed })
67 }
68
69 pub fn update(&self, params: ConfigUpdateParams) -> Result<ConfigUpdateResult, ApiError> {
70 let parsed = parse_yaml_to_json_object(¶ms.content)
71 .map_err(|error| ApiError::config_invalid(format!("invalid YAML syntax: {error}")))?;
72
73 safe_write(&self.config_path, ¶ms.content)?;
74
75 Ok(ConfigUpdateResult {
76 success: true,
77 parsed,
78 })
79 }
80}
81
82fn parse_yaml_to_json_object(content: &str) -> Result<serde_json::Map<String, Value>, String> {
83 let yaml_value: serde_yaml::Value =
84 serde_yaml::from_str(content).map_err(|error| error.to_string())?;
85 let json_value = serde_json::to_value(yaml_value).map_err(|error| error.to_string())?;
86
87 match json_value {
88 Value::Object(map) => Ok(map),
89 _ => Err("configuration root must be a YAML mapping/object".to_string()),
90 }
91}
92
93fn safe_write(path: &Path, content: &str) -> Result<(), ApiError> {
94 if let Some(parent) = path.parent() {
95 fs::create_dir_all(parent).map_err(|error| {
96 ApiError::internal(format!(
97 "failed creating config directory '{}': {error}",
98 parent.display()
99 ))
100 })?;
101 }
102
103 let nanos = SystemTime::now()
104 .duration_since(UNIX_EPOCH)
105 .map(|duration| duration.as_nanos())
106 .unwrap_or(0);
107
108 let temp_path = path.with_extension(format!("tmp-{}-{nanos}", std::process::id()));
109
110 fs::write(&temp_path, content).map_err(|error| {
111 ApiError::internal(format!(
112 "failed writing temporary config '{}': {error}",
113 temp_path.display()
114 ))
115 })?;
116
117 if let Err(error) = fs::rename(&temp_path, path) {
118 let _ = fs::remove_file(&temp_path);
119 return Err(ApiError::internal(format!(
120 "failed replacing config file '{}': {error}",
121 path.display()
122 )));
123 }
124
125 Ok(())
126}