ryra_core/registry/
mod.rs1pub mod bundled;
2pub mod fetch;
3pub mod manage;
4pub mod resolve;
5pub mod service_def;
6pub mod test_def;
7
8use std::path::{Path, PathBuf};
9
10use crate::error::{Error, Result};
11use service_def::ServiceDef;
12
13pub struct RegistryService {
15 pub def: ServiceDef,
16 pub service_dir: PathBuf,
18}
19
20pub fn find_service(repo_dir: &Path, name: &str) -> Result<RegistryService> {
22 let svc_dir = repo_dir.join(name);
23 let service_toml = svc_dir.join("service.toml");
24
25 if !service_toml.exists() {
26 return Err(Error::ServiceNotFound {
27 name: name.to_string(),
28 suggestions: suggest_close_names(repo_dir, name),
29 });
30 }
31
32 let contents = std::fs::read_to_string(&service_toml).map_err(|source| Error::FileRead {
33 path: service_toml.clone(),
34 source,
35 })?;
36 let def: ServiceDef = toml::from_str(&contents).map_err(|source| Error::TomlParse {
37 path: service_toml,
38 source,
39 })?;
40
41 if let Err(msg) = def.validate() {
42 return Err(Error::ConfigValidation(msg));
43 }
44
45 Ok(RegistryService {
46 def,
47 service_dir: svc_dir,
48 })
49}
50
51pub fn list_available(repo_dir: &Path) -> Result<Vec<RegistryService>> {
53 if !repo_dir.exists() {
54 return Ok(Vec::new());
55 }
56
57 let entries = std::fs::read_dir(repo_dir).map_err(|source| Error::FileRead {
58 path: repo_dir.to_path_buf(),
59 source,
60 })?;
61
62 let mut services = Vec::new();
63 for entry in entries {
64 let entry = entry.map_err(|source| Error::FileRead {
65 path: repo_dir.to_path_buf(),
66 source,
67 })?;
68 let svc_dir = entry.path();
69 let service_toml = svc_dir.join("service.toml");
70 if service_toml.exists() {
71 let contents =
72 std::fs::read_to_string(&service_toml).map_err(|source| Error::FileRead {
73 path: service_toml.clone(),
74 source,
75 })?;
76 let def: ServiceDef = toml::from_str(&contents).map_err(|source| Error::TomlParse {
77 path: service_toml,
78 source,
79 })?;
80 services.push(RegistryService {
81 def,
82 service_dir: svc_dir,
83 });
84 }
85 }
86
87 services.sort_by(|a, b| a.def.service.name.cmp(&b.def.service.name));
88 Ok(services)
89}
90
91fn suggest_close_names(repo_dir: &Path, name: &str) -> Vec<String> {
102 let Ok(entries) = std::fs::read_dir(repo_dir) else {
103 return Vec::new();
104 };
105 let candidates: Vec<String> = entries
106 .filter_map(|e| e.ok())
107 .filter(|e| e.path().join("service.toml").exists())
108 .filter_map(|e| e.file_name().into_string().ok())
109 .collect();
110 let max_dist = (name.len() / 3 + 1).min(3);
111 let mut scored: Vec<(usize, String)> = candidates
112 .into_iter()
113 .map(|c| (levenshtein(name, &c), c))
114 .filter(|(d, _)| *d <= max_dist)
115 .collect();
116 scored.sort_by_key(|(d, _)| *d);
117 scored.into_iter().take(3).map(|(_, n)| n).collect()
118}
119
120fn levenshtein(a: &str, b: &str) -> usize {
124 let a: Vec<char> = a.chars().flat_map(char::to_lowercase).collect();
125 let b: Vec<char> = b.chars().flat_map(char::to_lowercase).collect();
126 if a.is_empty() {
127 return b.len();
128 }
129 if b.is_empty() {
130 return a.len();
131 }
132 let mut dp: Vec<usize> = (0..=b.len()).collect();
133 for i in 1..=a.len() {
134 let mut prev = dp[0];
135 dp[0] = i;
136 for j in 1..=b.len() {
137 let temp = dp[j];
138 dp[j] = if a[i - 1] == b[j - 1] {
139 prev
140 } else {
141 1 + prev.min(dp[j].min(dp[j - 1]))
142 };
143 prev = temp;
144 }
145 }
146 dp[b.len()]
147}
148
149pub fn format_service_suggestions(suggestions: &[String]) -> String {
153 match suggestions {
154 [] => String::new(),
155 [one] => format!(" — did you mean '{one}'?"),
156 many => format!(" — did you mean one of: {}?", many.join(", ")),
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn levenshtein_basics() {
166 assert_eq!(levenshtein("seafile", "seafile"), 0);
167 assert_eq!(levenshtein("seafule", "seafile"), 1); assert_eq!(levenshtein("seafil", "seafile"), 1); assert_eq!(levenshtein("seafiles", "seafile"), 1); assert_eq!(levenshtein("SEAFILE", "seafile"), 0); }
172
173 #[test]
174 fn format_suggestions_shapes() {
175 assert_eq!(format_service_suggestions(&[]), "");
176 assert_eq!(
177 format_service_suggestions(&["seafile".into()]),
178 " — did you mean 'seafile'?"
179 );
180 assert_eq!(
181 format_service_suggestions(&["seafile".into(), "vikunja".into()]),
182 " — did you mean one of: seafile, vikunja?"
183 );
184 }
185}