wallr_core/packages/
mod.rs1use 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 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()], ®istry).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()], ®istry).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}