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