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