ryra_core/registry/
mod.rs1pub mod fetch;
2pub mod manage;
3pub mod resolve;
4pub mod service_def;
5pub mod test_def;
6
7use std::path::{Path, PathBuf};
8
9use crate::error::{Error, Result};
10use service_def::ServiceDef;
11
12#[derive(Debug, Default)]
17pub struct TrustReport {
18 pub quadlet_hooks: Vec<String>,
20 pub config_scripts: Vec<String>,
22 pub host_mounts: Vec<String>,
24}
25
26pub fn trust_report(service_dir: &Path) -> TrustReport {
30 let mut report = TrustReport::default();
31
32 let quadlet_dir = service_dir.join("quadlets");
33 if let Ok(entries) = std::fs::read_dir(&quadlet_dir) {
34 for entry in entries.flatten() {
35 if let Ok(content) = std::fs::read_to_string(entry.path()) {
36 for line in content.lines() {
37 let trimmed = line.trim();
38 if trimmed.starts_with("ExecStartPre=") || trimmed.starts_with("ExecStartPost=")
39 {
40 report.quadlet_hooks.push(trimmed.to_string());
41 }
42 if trimmed.starts_with("Volume=") {
43 let vol = trimmed.strip_prefix("Volume=").unwrap_or(trimmed);
44 if vol.contains("%h") || vol.starts_with('/') {
46 report.host_mounts.push(vol.to_string());
47 }
48 }
49 }
50 }
51 }
52 }
53
54 let scripts_dir = service_dir.join("configs").join("scripts");
55 if let Ok(entries) = std::fs::read_dir(&scripts_dir) {
56 for entry in entries.flatten() {
57 if let Some(name) = entry.file_name().to_str() {
58 report.config_scripts.push(name.to_string());
59 }
60 }
61 }
62
63 report
64}
65
66pub struct RegistryService {
68 pub def: ServiceDef,
69 pub service_dir: PathBuf,
71}
72
73pub fn find_service(repo_dir: &Path, name: &str) -> Result<RegistryService> {
75 let svc_dir = repo_dir.join(name);
76 let service_toml = svc_dir.join("service.toml");
77
78 if !service_toml.exists() {
79 if repo_dir.join("service.toml").exists() {
83 let project = load_project_service(repo_dir)?;
84 if project.def.service.name == name {
85 return Ok(project);
86 }
87 }
88 return Err(Error::ServiceNotFound {
89 name: name.to_string(),
90 suggestions: suggest_close_names(repo_dir, name),
91 });
92 }
93
94 let contents = std::fs::read_to_string(&service_toml).map_err(|source| Error::FileRead {
95 path: service_toml.clone(),
96 source,
97 })?;
98 let def: ServiceDef = toml::from_str(&contents).map_err(|source| Error::TomlParse {
99 path: service_toml,
100 source,
101 })?;
102
103 if let Err(msg) = def.validate() {
104 return Err(Error::ConfigValidation(msg));
105 }
106
107 Ok(RegistryService {
108 def,
109 service_dir: svc_dir,
110 })
111}
112
113pub fn load_project_service(dir: &Path) -> Result<RegistryService> {
117 let service_toml = dir.join("service.toml");
118 if !service_toml.exists() {
119 return Err(Error::ServiceNotFound {
120 name: format!("no service.toml in {}", dir.display()),
121 suggestions: Vec::new(),
122 });
123 }
124 let contents = std::fs::read_to_string(&service_toml).map_err(|source| Error::FileRead {
125 path: service_toml.clone(),
126 source,
127 })?;
128 let def: ServiceDef = toml::from_str(&contents).map_err(|source| Error::TomlParse {
129 path: service_toml,
130 source,
131 })?;
132 if let Err(msg) = def.validate() {
133 return Err(Error::ConfigValidation(msg));
134 }
135 Ok(RegistryService {
136 def,
137 service_dir: dir.to_path_buf(),
138 })
139}
140
141pub fn list_available(repo_dir: &Path) -> Result<Vec<RegistryService>> {
143 if !repo_dir.exists() {
144 return Ok(Vec::new());
145 }
146
147 let entries = std::fs::read_dir(repo_dir).map_err(|source| Error::FileRead {
148 path: repo_dir.to_path_buf(),
149 source,
150 })?;
151
152 let mut services = Vec::new();
153 for entry in entries {
154 let entry = entry.map_err(|source| Error::FileRead {
155 path: repo_dir.to_path_buf(),
156 source,
157 })?;
158 let svc_dir = entry.path();
159 let service_toml = svc_dir.join("service.toml");
160 if service_toml.exists() {
161 let contents =
162 std::fs::read_to_string(&service_toml).map_err(|source| Error::FileRead {
163 path: service_toml.clone(),
164 source,
165 })?;
166 let def: ServiceDef = toml::from_str(&contents).map_err(|source| Error::TomlParse {
167 path: service_toml,
168 source,
169 })?;
170 services.push(RegistryService {
171 def,
172 service_dir: svc_dir,
173 });
174 }
175 }
176
177 services.sort_by(|a, b| a.def.service.name.cmp(&b.def.service.name));
178 Ok(services)
179}
180
181fn suggest_close_names(repo_dir: &Path, name: &str) -> Vec<String> {
192 let Ok(entries) = std::fs::read_dir(repo_dir) else {
193 return Vec::new();
194 };
195 let candidates: Vec<String> = entries
196 .filter_map(|e| e.ok())
197 .filter(|e| e.path().join("service.toml").exists())
198 .filter_map(|e| e.file_name().into_string().ok())
199 .collect();
200 let max_dist = (name.len() / 3 + 1).min(3);
201 let mut scored: Vec<(usize, String)> = candidates
202 .into_iter()
203 .map(|c| (levenshtein(name, &c), c))
204 .filter(|(d, _)| *d <= max_dist)
205 .collect();
206 scored.sort_by_key(|(d, _)| *d);
207 scored.into_iter().take(3).map(|(_, n)| n).collect()
208}
209
210fn levenshtein(a: &str, b: &str) -> usize {
214 let a: Vec<char> = a.chars().flat_map(char::to_lowercase).collect();
215 let b: Vec<char> = b.chars().flat_map(char::to_lowercase).collect();
216 if a.is_empty() {
217 return b.len();
218 }
219 if b.is_empty() {
220 return a.len();
221 }
222 let mut dp: Vec<usize> = (0..=b.len()).collect();
223 for i in 1..=a.len() {
224 let mut prev = dp[0];
225 dp[0] = i;
226 for j in 1..=b.len() {
227 let temp = dp[j];
228 dp[j] = if a[i - 1] == b[j - 1] {
229 prev
230 } else {
231 1 + prev.min(dp[j].min(dp[j - 1]))
232 };
233 prev = temp;
234 }
235 }
236 dp[b.len()]
237}
238
239pub fn format_service_suggestions(suggestions: &[String]) -> String {
243 match suggestions {
244 [] => String::new(),
245 [one] => format!(" — did you mean '{one}'?"),
246 many => format!(" — did you mean one of: {}?", many.join(", ")),
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 #[test]
255 fn levenshtein_basics() {
256 assert_eq!(levenshtein("seafile", "seafile"), 0);
257 assert_eq!(levenshtein("seafule", "seafile"), 1); assert_eq!(levenshtein("seafil", "seafile"), 1); assert_eq!(levenshtein("seafiles", "seafile"), 1); assert_eq!(levenshtein("SEAFILE", "seafile"), 0); }
262
263 #[test]
264 fn format_suggestions_shapes() {
265 assert_eq!(format_service_suggestions(&[]), "");
266 assert_eq!(
267 format_service_suggestions(&["seafile".into()]),
268 " — did you mean 'seafile'?"
269 );
270 assert_eq!(
271 format_service_suggestions(&["seafile".into(), "vikunja".into()]),
272 " — did you mean one of: seafile, vikunja?"
273 );
274 }
275}