actr_cli/core/components/
config_manager.rs

1use 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}