Skip to main content

ryra_core/registry/
mod.rs

1pub 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
13/// Represents a service found in a repo, with its source info.
14pub struct RegistryService {
15    pub def: ServiceDef,
16    /// Path to the service directory (contains service.toml, compose files, etc.)
17    pub service_dir: PathBuf,
18}
19
20/// Find a service by name in a repo directory.
21pub 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
51/// List all available services in a repo directory.
52pub 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
91/// Up to three close-match service names from `repo_dir` for a typo'd
92/// `name`. The Levenshtein threshold is `len/3 + 1` (max 3) so short
93/// names get tighter matching — "for" shouldn't match "forgejo", but
94/// "forgeo" should. Bypasses [`list_available`]'s service.toml parse so
95/// we don't fail to suggest just because a sibling service has a
96/// malformed file: directory names alone are enough to compare.
97///
98/// Only called from [`find_service`] — `remove`/`config`/`test` errors
99/// already list or imply the small candidate set, so adding fuzzy
100/// suggestions there would be polish without payoff.
101fn 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
120/// Standalone iterative Levenshtein distance — case-insensitive so
121/// "Forgejo" vs "forgejo" doesn't add a phantom edit. No dependency,
122/// runs in O(n×m) time on rolling vectors.
123fn 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
149/// Render the trailing " — did you mean 'X'?" hint used by
150/// [`Error::ServiceNotFound`]. Empty when there are no suggestions, so
151/// users with truly unique typos don't see a stray prompt.
152pub 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); // substitution
168        assert_eq!(levenshtein("seafil", "seafile"), 1); // insertion
169        assert_eq!(levenshtein("seafiles", "seafile"), 1); // deletion
170        assert_eq!(levenshtein("SEAFILE", "seafile"), 0); // case-insensitive
171    }
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}