Skip to main content

systemprompt_loader/
config_writer.rs

1//! Writes individual agent files and patches the top-level config to
2//! drop their `includes:` entries.
3//!
4//! All operations are atomic at the per-file level; concurrent writers
5//! racing on the same agent file may overwrite each other and the loader
6//! does not attempt to lock the on-disk config.
7
8use std::collections::HashMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use systemprompt_models::services::AgentConfig;
13
14use crate::error::{ConfigWriteError, ConfigWriteResult};
15
16#[derive(Debug, Clone, Copy)]
17pub struct ConfigWriter;
18
19#[derive(serde::Serialize, serde::Deserialize)]
20struct AgentFileContent {
21    agents: HashMap<String, AgentConfig>,
22}
23
24impl ConfigWriter {
25    pub fn create_agent(agent: &AgentConfig, services_dir: &Path) -> ConfigWriteResult<PathBuf> {
26        let agents_dir = services_dir.join("agents");
27        fs::create_dir_all(&agents_dir).map_err(|e| ConfigWriteError::Io {
28            path: agents_dir.clone(),
29            source: e,
30        })?;
31
32        let agent_file = agents_dir.join(format!("{}.yaml", agent.name));
33
34        if agent_file.exists() {
35            return Err(ConfigWriteError::AgentFileExists(agent_file));
36        }
37
38        Self::write_agent_file(&agent_file, agent)?;
39
40        Ok(agent_file)
41    }
42
43    pub fn update_agent(
44        name: &str,
45        agent: &AgentConfig,
46        services_dir: &Path,
47    ) -> ConfigWriteResult<()> {
48        let agent_file = Self::find_agent_file(name, services_dir)?
49            .ok_or_else(|| ConfigWriteError::AgentNotFound(name.to_string()))?;
50
51        Self::write_agent_file(&agent_file, agent)
52    }
53
54    pub fn delete_agent(name: &str, services_dir: &Path) -> ConfigWriteResult<()> {
55        let agent_file = Self::find_agent_file(name, services_dir)?
56            .ok_or_else(|| ConfigWriteError::AgentNotFound(name.to_string()))?;
57
58        fs::remove_file(&agent_file).map_err(|e| ConfigWriteError::Io {
59            path: agent_file.clone(),
60            source: e,
61        })?;
62
63        let config_path = services_dir.join("config/config.yaml");
64        let include_path = format!("../agents/{name}.yaml");
65        Self::remove_include(&include_path, &config_path)
66    }
67
68    pub fn find_agent_file(name: &str, services_dir: &Path) -> ConfigWriteResult<Option<PathBuf>> {
69        let agents_dir = services_dir.join("agents");
70
71        if !agents_dir.exists() {
72            return Ok(None);
73        }
74
75        let expected_file = agents_dir.join(format!("{name}.yaml"));
76        if expected_file.exists() && Self::file_contains_agent(&expected_file, name)? {
77            return Ok(Some(expected_file));
78        }
79
80        for entry in fs::read_dir(&agents_dir).map_err(|e| ConfigWriteError::Io {
81            path: agents_dir.clone(),
82            source: e,
83        })? {
84            let path = entry
85                .map_err(|e| ConfigWriteError::Io {
86                    path: agents_dir.clone(),
87                    source: e,
88                })?
89                .path();
90
91            if path
92                .extension()
93                .is_some_and(|ext| ext == "yaml" || ext == "yml")
94                && Self::file_contains_agent(&path, name)?
95            {
96                return Ok(Some(path));
97            }
98        }
99
100        Ok(None)
101    }
102
103    fn file_contains_agent(path: &Path, agent_name: &str) -> ConfigWriteResult<bool> {
104        let content = fs::read_to_string(path).map_err(|e| ConfigWriteError::Io {
105            path: path.to_path_buf(),
106            source: e,
107        })?;
108
109        let parsed: AgentFileContent = serde_yaml::from_str(&content)?;
110
111        Ok(parsed.agents.contains_key(agent_name))
112    }
113
114    fn write_agent_file(path: &Path, agent: &AgentConfig) -> ConfigWriteResult<()> {
115        let mut agents = HashMap::new();
116        agents.insert(agent.name.clone(), agent.clone());
117
118        let content = AgentFileContent { agents };
119
120        let yaml = serde_yaml::to_string(&content)?;
121
122        let header = format!(
123            "# {} Configuration\n# {}\n\n",
124            agent.card.display_name, agent.card.description
125        );
126
127        fs::write(path, format!("{header}{yaml}")).map_err(|e| ConfigWriteError::Io {
128            path: path.to_path_buf(),
129            source: e,
130        })
131    }
132
133    fn remove_include(include_path: &str, config_path: &Path) -> ConfigWriteResult<()> {
134        let content = fs::read_to_string(config_path).map_err(|e| ConfigWriteError::Io {
135            path: config_path.to_path_buf(),
136            source: e,
137        })?;
138
139        let search_pattern = format!("  - {include_path}");
140        let quoted_pattern = format!("  - \"{include_path}\"");
141
142        let new_lines: Vec<&str> = content
143            .lines()
144            .filter(|line| *line != search_pattern && *line != quoted_pattern)
145            .collect();
146
147        fs::write(config_path, new_lines.join("\n")).map_err(|e| ConfigWriteError::Io {
148            path: config_path.to_path_buf(),
149            source: e,
150        })
151    }
152}