Skip to main content

ryra_core/registry/
mod.rs

1pub 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/// Represents a service found in a repo, with its source info.
13pub struct RegistryService {
14    pub def: ServiceDef,
15    /// Path to the service directory (contains service.toml, compose files, etc.)
16    pub service_dir: PathBuf,
17}
18
19/// Find a service by name in a repo directory.
20pub fn find_service(repo_dir: &Path, name: &str) -> Result<RegistryService> {
21    let svc_dir = repo_dir.join(name);
22    let service_toml = svc_dir.join("service.toml");
23
24    if !service_toml.exists() {
25        return Err(Error::ServiceNotFound {
26            name: name.to_string(),
27            suggestions: suggest_close_names(repo_dir, name),
28        });
29    }
30
31    let contents = std::fs::read_to_string(&service_toml).map_err(|source| Error::FileRead {
32        path: service_toml.clone(),
33        source,
34    })?;
35    let def: ServiceDef = toml::from_str(&contents).map_err(|source| Error::TomlParse {
36        path: service_toml,
37        source,
38    })?;
39
40    if let Err(msg) = def.validate() {
41        return Err(Error::ConfigValidation(msg));
42    }
43
44    Ok(RegistryService {
45        def,
46        service_dir: svc_dir,
47    })
48}
49
50/// List all available services in a repo directory.
51pub fn list_available(repo_dir: &Path) -> Result<Vec<RegistryService>> {
52    if !repo_dir.exists() {
53        return Ok(Vec::new());
54    }
55
56    let entries = std::fs::read_dir(repo_dir).map_err(|source| Error::FileRead {
57        path: repo_dir.to_path_buf(),
58        source,
59    })?;
60
61    let mut services = Vec::new();
62    for entry in entries {
63        let entry = entry.map_err(|source| Error::FileRead {
64            path: repo_dir.to_path_buf(),
65            source,
66        })?;
67        let svc_dir = entry.path();
68        let service_toml = svc_dir.join("service.toml");
69        if service_toml.exists() {
70            let contents =
71                std::fs::read_to_string(&service_toml).map_err(|source| Error::FileRead {
72                    path: service_toml.clone(),
73                    source,
74                })?;
75            let def: ServiceDef = toml::from_str(&contents).map_err(|source| Error::TomlParse {
76                path: service_toml,
77                source,
78            })?;
79            services.push(RegistryService {
80                def,
81                service_dir: svc_dir,
82            });
83        }
84    }
85
86    services.sort_by(|a, b| a.def.service.name.cmp(&b.def.service.name));
87    Ok(services)
88}
89
90/// Up to three close-match service names from `repo_dir` for a typo'd
91/// `name`. The Levenshtein threshold is `len/3 + 1` (max 3) so short
92/// names get tighter matching — "for" shouldn't match "forgejo", but
93/// "forgeo" should. Bypasses [`list_available`]'s service.toml parse so
94/// we don't fail to suggest just because a sibling service has a
95/// malformed file: directory names alone are enough to compare.
96///
97/// Only called from [`find_service`] — `remove`/`config`/`test` errors
98/// already list or imply the small candidate set, so adding fuzzy
99/// suggestions there would be polish without payoff.
100fn suggest_close_names(repo_dir: &Path, name: &str) -> Vec<String> {
101    let Ok(entries) = std::fs::read_dir(repo_dir) else {
102        return Vec::new();
103    };
104    let candidates: Vec<String> = entries
105        .filter_map(|e| e.ok())
106        .filter(|e| e.path().join("service.toml").exists())
107        .filter_map(|e| e.file_name().into_string().ok())
108        .collect();
109    let max_dist = (name.len() / 3 + 1).min(3);
110    let mut scored: Vec<(usize, String)> = candidates
111        .into_iter()
112        .map(|c| (levenshtein(name, &c), c))
113        .filter(|(d, _)| *d <= max_dist)
114        .collect();
115    scored.sort_by_key(|(d, _)| *d);
116    scored.into_iter().take(3).map(|(_, n)| n).collect()
117}
118
119/// Standalone iterative Levenshtein distance — case-insensitive so
120/// "Forgejo" vs "forgejo" doesn't add a phantom edit. No dependency,
121/// runs in O(n×m) time on rolling vectors.
122fn levenshtein(a: &str, b: &str) -> usize {
123    let a: Vec<char> = a.chars().flat_map(char::to_lowercase).collect();
124    let b: Vec<char> = b.chars().flat_map(char::to_lowercase).collect();
125    if a.is_empty() {
126        return b.len();
127    }
128    if b.is_empty() {
129        return a.len();
130    }
131    let mut dp: Vec<usize> = (0..=b.len()).collect();
132    for i in 1..=a.len() {
133        let mut prev = dp[0];
134        dp[0] = i;
135        for j in 1..=b.len() {
136            let temp = dp[j];
137            dp[j] = if a[i - 1] == b[j - 1] {
138                prev
139            } else {
140                1 + prev.min(dp[j].min(dp[j - 1]))
141            };
142            prev = temp;
143        }
144    }
145    dp[b.len()]
146}
147
148/// Render the trailing " — did you mean 'X'?" hint used by
149/// [`Error::ServiceNotFound`]. Empty when there are no suggestions, so
150/// users with truly unique typos don't see a stray prompt.
151pub fn format_service_suggestions(suggestions: &[String]) -> String {
152    match suggestions {
153        [] => String::new(),
154        [one] => format!(" — did you mean '{one}'?"),
155        many => format!(" — did you mean one of: {}?", many.join(", ")),
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn levenshtein_basics() {
165        assert_eq!(levenshtein("seafile", "seafile"), 0);
166        assert_eq!(levenshtein("seafule", "seafile"), 1); // substitution
167        assert_eq!(levenshtein("seafil", "seafile"), 1); // insertion
168        assert_eq!(levenshtein("seafiles", "seafile"), 1); // deletion
169        assert_eq!(levenshtein("SEAFILE", "seafile"), 0); // case-insensitive
170    }
171
172    #[test]
173    fn format_suggestions_shapes() {
174        assert_eq!(format_service_suggestions(&[]), "");
175        assert_eq!(
176            format_service_suggestions(&["seafile".into()]),
177            " — did you mean 'seafile'?"
178        );
179        assert_eq!(
180            format_service_suggestions(&["seafile".into(), "vikunja".into()]),
181            " — did you mean one of: seafile, vikunja?"
182        );
183    }
184}