Skip to main content

wallr_core/packages/
mod.rs

1use crate::animation::AnimationSpec;
2use std::collections::HashSet;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6#[derive(Debug, thiserror::Error)]
7pub enum PackageError {
8    #[error("package not found: {0}")]
9    NotFound(String),
10    #[error("failed to read package: {0}")]
11    ReadError(#[from] std::io::Error),
12    #[error("failed to parse package: {0}")]
13    ParseError(#[from] serde_yaml::Error),
14    #[error("circular extends detected: {0}")]
15    CircularExtends(String),
16    #[error("invalid package reference: {0}")]
17    InvalidReference(String),
18    #[error("animation error: {0}")]
19    AnimationError(#[from] crate::animation::AnimationError),
20    #[error("Failed to download remote package: {0}")]
21    DownloadError(String),
22}
23
24pub struct Package {
25    pub name: String,
26    pub path: PathBuf,
27    pub spec: AnimationSpec,
28}
29
30pub struct PackageRegistry {
31    pub packages_dir: PathBuf,
32}
33
34impl PackageRegistry {
35    pub fn new() -> Result<Self, PackageError> {
36        let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
37        let packages_dir = PathBuf::from(home).join(".local/share/wallr/packages");
38        if !packages_dir.exists() {
39            fs::create_dir_all(&packages_dir)?;
40        }
41        Ok(Self { packages_dir })
42    }
43
44    pub fn load_package(&self, name: &str) -> Result<Package, PackageError> {
45        let pkg_path = self.packages_dir.join(name);
46        if !pkg_path.exists() {
47            return Err(PackageError::NotFound(name.to_string()));
48        }
49
50        let yaml_path = ["wallr.yaml", "animation.yaml"]
51            .iter()
52            .map(|file| pkg_path.join(file))
53            .find(|path| path.is_file())
54            .ok_or_else(|| PackageError::NotFound(format!("{name} (missing wallr.yaml)")))?;
55        let content = fs::read_to_string(&yaml_path)?;
56        let spec = crate::animation::parse_animation_yaml(&content)?;
57
58        Ok(Package {
59            name: name.to_string(),
60            path: pkg_path,
61            spec,
62        })
63    }
64
65    pub fn resolve_animation(&self, reference: &str) -> Result<AnimationSpec, PackageError> {
66        let parts: Vec<&str> = reference.split('/').collect();
67        if parts.len() != 2 {
68            return Err(PackageError::InvalidReference(reference.to_string()));
69        }
70
71        let pkg_name = parts[0];
72        let package = self.load_package(pkg_name)?;
73
74        Ok(package.spec)
75    }
76
77    pub fn list_packages(&self) -> Result<Vec<String>, PackageError> {
78        let mut packages = Vec::new();
79        if !self.packages_dir.exists() {
80            return Ok(packages);
81        }
82
83        for entry in fs::read_dir(&self.packages_dir)? {
84            let entry = entry?;
85            if entry.metadata()?.is_dir()
86                && let Some(name) = entry.file_name().to_str()
87            {
88                packages.push(name.to_string());
89            }
90        }
91        Ok(packages)
92    }
93}
94
95pub fn resolve_extends(
96    mut base: AnimationSpec,
97    extends: &[String],
98    registry: &PackageRegistry,
99) -> Result<AnimationSpec, PackageError> {
100    detect_cycles(extends, registry)?;
101
102    for parent_ref in extends {
103        let parent_spec = if parent_ref.starts_with("github:") {
104            fetch_remote_package(parent_ref)?
105        } else {
106            registry.resolve_animation(parent_ref)?
107        };
108
109        if base.duration.is_none() {
110            base.duration = parent_spec.duration;
111        }
112        if base.timeline.is_none() {
113            base.timeline = parent_spec.timeline;
114        }
115        for (key, value) in parent_spec.variables {
116            base.variables.entry(key).or_insert(value);
117        }
118        for (key, value) in parent_spec.custom_effects {
119            base.custom_effects.entry(key).or_insert(value);
120        }
121        for effect in parent_spec.effects {
122            if !base.effects.contains(&effect) {
123                base.effects.push(effect);
124            }
125        }
126    }
127    crate::animation::validate_animation(&base).map_err(|errors| {
128        PackageError::DownloadError(
129            errors
130                .into_iter()
131                .map(|e| e.to_string())
132                .collect::<Vec<_>>()
133                .join("; "),
134        )
135    })?;
136    Ok(base)
137}
138
139pub fn fetch_remote_package(reference: &str) -> Result<AnimationSpec, PackageError> {
140    let stripped = reference.strip_prefix("github:").unwrap_or(reference);
141    let subparts: Vec<&str> = stripped.split('/').collect();
142    if subparts.len() != 2
143        || subparts
144            .iter()
145            .any(|part| part.is_empty() || part.contains('@'))
146    {
147        return Err(PackageError::InvalidReference(reference.to_string()));
148    }
149
150    let owner = subparts[0];
151    let repo = subparts[1];
152    let tag = "main";
153
154    // Check local cache first
155    let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
156    let cache_dir = PathBuf::from(home).join(".cache/wallr/packages");
157    if !cache_dir.exists() {
158        let _ = fs::create_dir_all(&cache_dir);
159    }
160
161    let cache_file = cache_dir.join(format!("{}_{}.yaml", owner, repo));
162
163    if cache_file.exists() {
164        let cached_content = fs::read_to_string(&cache_file)?;
165        let spec = crate::animation::parse_animation_yaml(&cached_content)?;
166        return Ok(spec);
167    }
168
169    let candidates = vec!["wallr.yaml".to_string(), format!("{repo}.yaml")];
170    let mut output = None;
171    for file_path in candidates {
172        let url = format!("https://raw.githubusercontent.com/{owner}/{repo}/{tag}/{file_path}");
173        let candidate = std::process::Command::new("curl")
174            .arg("-fsSL")
175            .arg(&url)
176            .output();
177        if let Ok(out) = candidate
178            && out.status.success()
179            && !out.stdout.is_empty()
180        {
181            output = Some(out);
182            break;
183        }
184    }
185
186    match output {
187        Some(out) => {
188            let content = String::from_utf8_lossy(&out.stdout).to_string();
189            let spec = crate::animation::parse_animation_yaml(&content)?;
190            crate::animation::validate_animation(&spec).map_err(|errors| {
191                PackageError::DownloadError(
192                    errors
193                        .into_iter()
194                        .map(|e| e.to_string())
195                        .collect::<Vec<_>>()
196                        .join("; "),
197                )
198            })?;
199            let _ = fs::write(&cache_file, &content);
200            Ok(spec)
201        }
202        None => Err(PackageError::DownloadError(format!(
203            "unable to fetch {owner}/{repo} from GitHub"
204        ))),
205    }
206}
207
208pub fn detect_cycles(extends: &[String], _registry: &PackageRegistry) -> Result<(), PackageError> {
209    let mut visited = HashSet::new();
210    let mut active = HashSet::new();
211    if extends.len() != extends.iter().collect::<HashSet<_>>().len() {
212        let duplicate = extends
213            .iter()
214            .find(|reference| {
215                extends
216                    .iter()
217                    .filter(|candidate| *candidate == *reference)
218                    .count()
219                    > 1
220            })
221            .cloned()
222            .unwrap_or_default();
223        return Err(PackageError::CircularExtends(duplicate));
224    }
225    fn visit(
226        reference: &str,
227        registry: &PackageRegistry,
228        visited: &mut HashSet<String>,
229        active: &mut HashSet<String>,
230    ) -> Result<(), PackageError> {
231        if active.contains(reference) {
232            return Err(PackageError::CircularExtends(reference.to_string()));
233        }
234        if !visited.insert(reference.to_string()) {
235            return Ok(());
236        }
237        active.insert(reference.to_string());
238        if !reference.starts_with("github:") {
239            let package_name = reference.split('/').next().unwrap_or(reference);
240            if let Ok(package) = registry.load_package(package_name) {
241                for parent in &package.spec.extends {
242                    visit(parent, registry, visited, active)?;
243                }
244            }
245        }
246        active.remove(reference);
247        Ok(())
248    }
249    for ext in extends {
250        visit(ext, _registry, &mut visited, &mut active)?;
251    }
252    Ok(())
253}
254
255pub fn load_local_animation(path: &Path) -> Result<AnimationSpec, PackageError> {
256    let content = fs::read_to_string(path)?;
257    let spec = crate::animation::parse_animation_yaml(&content)?;
258    Ok(spec)
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn test_detect_no_cycles() {
267        let registry = PackageRegistry {
268            packages_dir: PathBuf::from("/tmp"),
269        };
270        assert!(detect_cycles(&["base".to_string(), "common".to_string()], &registry).is_ok());
271    }
272
273    #[test]
274    fn test_detect_cycles() {
275        let registry = PackageRegistry {
276            packages_dir: PathBuf::from("/tmp"),
277        };
278        assert!(detect_cycles(&["base".to_string(), "base".to_string()], &registry).is_err());
279    }
280
281    #[test]
282    fn test_list_packages_empty() {
283        let registry = PackageRegistry {
284            packages_dir: PathBuf::from("/does/not/exist"),
285        };
286        let pkgs = registry.list_packages().unwrap();
287        assert!(pkgs.is_empty());
288    }
289}