/// Manifest management utilities for .project/manifest.toml /// /// Provides functions to read, write, and manipulate manifest.toml files /// that track installed resources in external projects. use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; /// Resource type classification #[derive(Debug, Clone, PartialEq, Eq)] pub enum ResourceType { Symlink, Local, Copy, Generated, } impl ResourceType { /// Parse from string pub fn parse(s: &str) -> Self { match s.to_lowercase().as_str() { "symlink" => ResourceType::Symlink, "local" => ResourceType::Local, "copy" => ResourceType::Copy, "generated" => ResourceType::Generated, _ => ResourceType::Copy, } } /// Convert to string pub fn as_str(&self) -> &'static str { match self { ResourceType::Symlink => "symlink", ResourceType::Local => "local", ResourceType::Copy => "copy", ResourceType::Generated => "generated", } } } /// Configuration entry in manifest #[derive(Debug, Clone)] pub struct ConfigEntry { pub name: String, pub enabled: bool, pub resource_type: ResourceType, pub path: String, pub modified: bool, } /// Manifest structure #[derive(Debug)] pub struct Manifest { pub tools_path: PathBuf, pub resource_type: ResourceType, pub configs: HashMap, } impl Manifest { /// Load manifest from path pub fn load(path: &Path) -> Result { if !path.exists() { return Err(format!("Manifest not found: {}", path.display())); } let content = fs::read_to_string(path).map_err(|e| format!("Failed to read manifest: {}", e))?; Self::from_toml(&content) } /// Parse manifest from TOML string pub fn from_toml(content: &str) -> Result { // Simple TOML parsing for manifest let mut tools_path = PathBuf::new(); let mut resource_type = ResourceType::Symlink; let mut configs = HashMap::new(); for line in content.lines() { let line = line.trim(); // Parse tools_path if line.starts_with("tools_path") && line.contains('=') { if let Some(value) = extract_toml_value(line) { tools_path = PathBuf::from(value); } } // Parse resource_type if line.starts_with("resource_type") && line.contains('=') { if let Some(value) = extract_toml_value(line) { resource_type = ResourceType::parse(&value); } } // Parse config entries [configs] section if line.starts_with("doc-lifecycle") || line.starts_with("lifecycle") || line.starts_with("tracking") || line.starts_with("presentation") { if let Some((name, entry)) = parse_config_entry(line) { configs.insert(name, entry); } } } Ok(Manifest { tools_path, resource_type, configs, }) } /// Save manifest to file pub fn save(&self, path: &Path) -> Result<(), String> { let toml = self.to_toml(); fs::write(path, toml).map_err(|e| format!("Failed to write manifest: {}", e)) } /// Convert to TOML string pub fn to_toml(&self) -> String { let mut output = String::new(); output.push_str("# Project Tools Manifest\n"); output.push_str("[project-tools]\n"); output.push_str(&format!("tools_path = \"{}\"\n", self.tools_path.display())); output.push_str(&format!( "resource_type = \"{}\"\n", self.resource_type.as_str() )); output.push_str("\n[configs]\n"); for (name, entry) in &self.configs { output.push_str(&format!("{} = {{ ", name)); output.push_str(&format!("enabled = {}, ", entry.enabled)); output.push_str(&format!("type = \"{}\", ", entry.resource_type.as_str())); output.push_str(&format!("path = \"{}\", ", entry.path)); output.push_str(&format!("modified = {}", entry.modified)); output.push_str(" }\n"); } output } /// Enable a configuration pub fn enable(&mut self, name: &str) { if let Some(entry) = self.configs.get_mut(name) { entry.enabled = true; } } /// Disable a configuration pub fn disable(&mut self, name: &str) { if let Some(entry) = self.configs.get_mut(name) { entry.enabled = false; } } /// Mark a configuration as modified pub fn mark_modified(&mut self, name: &str) { if let Some(entry) = self.configs.get_mut(name) { entry.modified = true; entry.resource_type = ResourceType::Local; } } /// Get enabled configurations pub fn enabled_configs(&self) -> Vec<&ConfigEntry> { self.configs.values().filter(|e| e.enabled).collect() } /// Check if config is enabled pub fn is_enabled(&self, name: &str) -> bool { self.configs.get(name).map(|e| e.enabled).unwrap_or(false) } } /// Helper: Extract value from TOML line (key = "value") fn extract_toml_value(line: &str) -> Option { if let Some(pos) = line.find('=') { let value_part = line[pos + 1..].trim(); if value_part.starts_with('"') && value_part.ends_with('"') { return Some(value_part[1..value_part.len() - 1].to_string()); } } None } /// Helper: Parse a config entry line fn parse_config_entry(line: &str) -> Option<(String, ConfigEntry)> { if !line.contains('=') { return None; } let parts: Vec<&str> = line.split('=').collect(); if parts.is_empty() { return None; } let name = parts[0].trim().to_string(); let config_str = parts[1..].join("="); // Simple parsing of { enabled = true, type = "symlink", path = "...", modified = false } let enabled = config_str.contains("enabled = true"); let resource_type = if config_str.contains("type = \"symlink\"") { ResourceType::Symlink } else if config_str.contains("type = \"local\"") { ResourceType::Local } else if config_str.contains("type = \"generated\"") { ResourceType::Generated } else { ResourceType::Copy }; let path = if let Some(start) = config_str.find("path = \"") { let start = start + 8; if let Some(end) = config_str[start..].find('"') { config_str[start..start + end].to_string() } else { format!(".project/{}", name) } } else { format!(".project/{}", name) }; let modified = config_str.contains("modified = true"); Some(( name.clone(), ConfigEntry { name, enabled, resource_type, path, modified, }, )) } #[cfg(test)] mod tests { use super::*; #[test] fn test_resource_type_conversion() { assert_eq!(ResourceType::parse("symlink"), ResourceType::Symlink); assert_eq!(ResourceType::parse("SYMLINK"), ResourceType::Symlink); assert_eq!(ResourceType::parse("local"), ResourceType::Local); assert_eq!(ResourceType::Symlink.as_str(), "symlink"); } #[test] fn test_manifest_from_toml() { let toml = r#" [project-tools] tools_path = "/Users/example/Tools" resource_type = "symlink" [configs] tracking = { enabled = true, type = "symlink", path = ".project/tracking.toml", modified = false } lifecycle = { enabled = false, type = "copy", path = ".project/lifecycle.toml", modified = true } "#; let manifest = Manifest::from_toml(toml).expect("Failed to parse"); assert_eq!( manifest.tools_path.to_str().unwrap(), "/Users/example/Tools" ); assert_eq!(manifest.resource_type, ResourceType::Symlink); assert_eq!(manifest.configs.len(), 2); assert!(manifest.is_enabled("tracking")); assert!(!manifest.is_enabled("lifecycle")); } #[test] fn test_manifest_enable_disable() { let toml = r#" [project-tools] tools_path = "/Users/example/Tools" resource_type = "symlink" [configs] tracking = { enabled = false, type = "symlink", path = ".project/tracking.toml", modified = false } "#; let mut manifest = Manifest::from_toml(toml).expect("Failed to parse"); assert!(!manifest.is_enabled("tracking")); manifest.enable("tracking"); assert!(manifest.is_enabled("tracking")); manifest.disable("tracking"); assert!(!manifest.is_enabled("tracking")); } #[test] fn test_manifest_mark_modified() { let toml = r#" [project-tools] tools_path = "/Users/example/Tools" resource_type = "symlink" [configs] tracking = { enabled = true, type = "symlink", path = ".project/tracking.toml", modified = false } "#; let mut manifest = Manifest::from_toml(toml).expect("Failed to parse"); let entry = manifest.configs.get("tracking").unwrap(); assert_eq!(entry.resource_type, ResourceType::Symlink); assert!(!entry.modified); manifest.mark_modified("tracking"); let entry = manifest.configs.get("tracking").unwrap(); assert_eq!(entry.resource_type, ResourceType::Local); assert!(entry.modified); } #[test] fn test_manifest_to_toml() { let toml = r#" [project-tools] tools_path = "/Users/example/Tools" resource_type = "symlink" [configs] tracking = { enabled = true, type = "symlink", path = ".project/tracking.toml", modified = false } "#; let manifest = Manifest::from_toml(toml).expect("Failed to parse"); let output = manifest.to_toml(); assert!(output.contains("tools_path = \"/Users/example/Tools\"")); assert!(output.contains("resource_type = \"symlink\"")); assert!(output.contains("enabled = true")); } #[test] fn test_enabled_configs() { let toml = r#" [project-tools] tools_path = "/Users/example/Tools" resource_type = "symlink" [configs] tracking = { enabled = true, type = "symlink", path = ".project/tracking.toml", modified = false } lifecycle = { enabled = false, type = "copy", path = ".project/lifecycle.toml", modified = false } presentation = { enabled = true, type = "symlink", path = ".project/presentation.toml", modified = false } "#; let manifest = Manifest::from_toml(toml).expect("Failed to parse"); let enabled = manifest.enabled_configs(); assert_eq!(enabled.len(), 2); assert!(enabled.iter().any(|e| e.name == "tracking")); assert!(enabled.iter().any(|e| e.name == "presentation")); } }