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