actr_cli/core/components/
config_manager.rs1use actr_config::{Config, ConfigParser};
2use actr_protocol::ActrTypeExt;
3use anyhow::{Context, Result};
4use async_trait::async_trait;
5use std::path::{Path, PathBuf};
6use std::time::SystemTime;
7use tokio::fs;
8use toml_edit::{DocumentMut, InlineTable, Item, Table, Value};
9
10use crate::core::{ConfigBackup, ConfigManager, ConfigValidation, DependencySpec};
11
12pub struct TomlConfigManager {
13 config_path: PathBuf,
14 project_root: PathBuf,
15}
16
17impl TomlConfigManager {
18 pub fn new<P: Into<PathBuf>>(config_path: P) -> Self {
19 let config_path = config_path.into();
20 let project_root = resolve_project_root(&config_path);
21 Self {
22 config_path,
23 project_root,
24 }
25 }
26
27 async fn read_config_string(&self, path: &Path) -> Result<String> {
28 fs::read_to_string(path)
29 .await
30 .with_context(|| format!("Failed to read config file: {}", path.display()))
31 }
32
33 async fn write_config_string(&self, path: &Path, contents: &str) -> Result<()> {
34 fs::write(path, contents)
35 .await
36 .with_context(|| format!("Failed to write config file: {}", path.display()))
37 }
38
39 fn build_backup_path(&self) -> Result<PathBuf> {
40 let file_name = self
41 .config_path
42 .file_name()
43 .ok_or_else(|| anyhow::anyhow!("Config path is missing file name"))?
44 .to_string_lossy();
45 let timestamp = SystemTime::now()
46 .duration_since(SystemTime::UNIX_EPOCH)
47 .unwrap_or_default()
48 .as_secs();
49 let backup_name = format!("{file_name}.bak.{timestamp}");
50 let parent = self
51 .config_path
52 .parent()
53 .filter(|p| !p.as_os_str().is_empty())
54 .unwrap_or_else(|| Path::new("."));
55 Ok(parent.join(backup_name))
56 }
57}
58
59#[async_trait]
60impl ConfigManager for TomlConfigManager {
61 async fn load_config(&self, path: &Path) -> Result<Config> {
62 ConfigParser::from_file(path)
63 .with_context(|| format!("Failed to parse config: {}", path.display()))
64 }
65
66 async fn save_config(&self, _config: &Config, _path: &Path) -> Result<()> {
67 Err(anyhow::anyhow!(
68 "Saving parsed Config is not supported; update Actr.toml directly"
69 ))
70 }
71
72 async fn update_dependency(&self, spec: &DependencySpec) -> Result<()> {
73 let contents = self.read_config_string(&self.config_path).await?;
74 let mut doc = contents
75 .parse::<DocumentMut>()
76 .with_context(|| format!("Failed to parse config: {}", self.config_path.display()))?;
77
78 if !doc.contains_key("dependencies") {
79 doc["dependencies"] = Item::Table(Table::new());
80 }
81
82 let existing_dep = doc["dependencies"]
84 .get(&spec.alias)
85 .and_then(|item| item.as_inline_table());
86
87 let mut dep_table = InlineTable::new();
88
89 if spec.name != spec.alias {
91 dep_table.insert("name", Value::from(spec.name.clone()));
92 }
93
94 if let Some(actr_type) = &spec.actr_type {
96 let actr_type_repr = actr_type.to_string_repr();
97 if actr_type_repr.is_empty() {
98 return Err(anyhow::anyhow!(
99 "Actr type is required for dependency: {}",
100 spec.alias
101 ));
102 }
103 dep_table.insert("actr_type", Value::from(actr_type_repr));
104 } else if let Some(existing) = existing_dep {
105 if let Some(existing_actr_type) = existing.get("actr_type") {
107 dep_table.insert("actr_type", existing_actr_type.clone());
108 }
109 }
110
111 if let Some(fingerprint) = &spec.fingerprint {
113 dep_table.insert("fingerprint", Value::from(fingerprint.as_str()));
114 } else if let Some(existing) = existing_dep {
115 if let Some(existing_fp) = existing.get("fingerprint") {
117 dep_table.insert("fingerprint", existing_fp.clone());
118 }
119 }
120
121 doc["dependencies"][&spec.alias] = Item::Value(Value::InlineTable(dep_table));
122
123 self.write_config_string(&self.config_path, &doc.to_string())
124 .await
125 }
126
127 async fn validate_config(&self) -> Result<ConfigValidation> {
128 let mut errors = Vec::new();
129 let warnings = Vec::new();
130
131 let config = match ConfigParser::from_file(&self.config_path) {
132 Ok(config) => config,
133 Err(e) => {
134 errors.push(format!("Failed to parse config: {e}"));
135 return Ok(ConfigValidation {
136 is_valid: false,
137 errors,
138 warnings,
139 });
140 }
141 };
142
143 if config.package.name.trim().is_empty() {
144 errors.push("package.name is required".to_string());
145 }
146
147 for dependency in &config.dependencies {
148 if dependency.alias.trim().is_empty() {
149 errors.push("dependency alias is required".to_string());
150 }
151 if let Some(actr_type) = &dependency.actr_type
152 && actr_type.name.trim().is_empty()
153 {
154 errors.push(format!(
155 "dependency {} has an empty actr_type name",
156 dependency.alias
157 ));
158 }
159 }
160
161 Ok(ConfigValidation {
162 is_valid: errors.is_empty(),
163 errors,
164 warnings,
165 })
166 }
167
168 fn get_project_root(&self) -> &Path {
169 &self.project_root
170 }
171
172 async fn backup_config(&self) -> Result<ConfigBackup> {
173 if !self.config_path.exists() {
174 return Err(anyhow::anyhow!(
175 "Config file not found: {}",
176 self.config_path.display()
177 ));
178 }
179
180 let backup_path = self.build_backup_path()?;
181 fs::copy(&self.config_path, &backup_path)
182 .await
183 .with_context(|| {
184 format!(
185 "Failed to backup config from {} to {}",
186 self.config_path.display(),
187 backup_path.display()
188 )
189 })?;
190
191 Ok(ConfigBackup {
192 original_path: self.config_path.clone(),
193 backup_path,
194 timestamp: SystemTime::now(),
195 })
196 }
197
198 async fn restore_backup(&self, backup: ConfigBackup) -> Result<()> {
199 fs::copy(&backup.backup_path, &backup.original_path)
200 .await
201 .with_context(|| {
202 format!(
203 "Failed to restore config from {} to {}",
204 backup.backup_path.display(),
205 backup.original_path.display()
206 )
207 })?;
208 Ok(())
209 }
210
211 async fn remove_backup(&self, backup: ConfigBackup) -> Result<()> {
212 if backup.backup_path.exists() {
213 fs::remove_file(&backup.backup_path)
214 .await
215 .with_context(|| {
216 format!(
217 "Failed to remove backup file: {}",
218 backup.backup_path.display()
219 )
220 })?;
221 }
222 Ok(())
223 }
224}
225
226fn resolve_project_root(config_path: &Path) -> PathBuf {
227 let canonical_path =
228 std::fs::canonicalize(config_path).expect("Failed to canonicalize config path");
229 canonical_path
230 .parent()
231 .expect("Config path must have a parent directory")
232 .to_path_buf()
233}