Skip to main content

ralph_api/
config_domain.rs

1use 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(&params.content)
71            .map_err(|error| ApiError::config_invalid(format!("invalid YAML syntax: {error}")))?;
72
73        safe_write(&self.config_path, &params.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}