Skip to main content

actr_cli/core/components/
config_manager.rs

1use 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        // Preserve existing dependency entry if it exists
83        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        // Add name attribute if it differs from alias
90        if spec.name != spec.alias {
91            dep_table.insert("name", Value::from(spec.name.clone()));
92        }
93
94        // Add actr_type attribute - preserve existing if new one is not provided
95        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            // Preserve existing actr_type if new spec doesn't have one
106            if let Some(existing_actr_type) = existing.get("actr_type") {
107                dep_table.insert("actr_type", existing_actr_type.clone());
108            }
109        }
110
111        // Add fingerprint - preserve existing if new one is not provided
112        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            // Preserve existing fingerprint if new spec doesn't have one
116            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}