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/// What a service definition is allowed to do on the host: quadlet hooks
13/// that run as the user, scripts copied into the service data dir, and
14/// host bind mounts. Collected so the frontend can show the user exactly
15/// what they're trusting before installing from an external registry.
16#[derive(Debug, Default)]
17pub struct TrustReport {
18    /// Raw `ExecStartPre=` / `ExecStartPost=` lines from the quadlets.
19    pub quadlet_hooks: Vec<String>,
20    /// Script filenames under `configs/scripts/`.
21    pub config_scripts: Vec<String>,
22    /// `Volume=` values that bind-mount host paths (`%h` or absolute).
23    pub host_mounts: Vec<String>,
24}
25
26/// Scan a service directory for everything that touches the host. Best
27/// effort: unreadable dirs/files contribute nothing rather than erroring,
28/// since the report is advisory (the install gate is the user's y/n).
29pub 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                        // Only flag host bind mounts (contain %h or start with /)
45                        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
66/// Represents a service found in a repo, with its source info.
67pub struct RegistryService {
68    pub def: ServiceDef,
69    /// Path to the service directory (contains service.toml, compose files, etc.)
70    pub service_dir: PathBuf,
71}
72
73/// Find a service by name in a repo directory.
74pub 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        // Project layout: `repo_dir/service.toml` directly (no `<name>/`
80        // subdir), used when `repo_dir` is itself a single-service project.
81        // Accept it when its declared name matches what was requested.
82        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
113/// Load a single-service project: `<dir>/service.toml` (no `<name>/` subdir),
114/// the way `cargo` reads the `Cargo.toml` in your cwd. The service name comes
115/// from inside the file. Used for `ryra add .` / `ryra add ./path`.
116pub 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
141/// List all available services in a repo directory.
142pub 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
181/// Up to three close-match service names from `repo_dir` for a typo'd
182/// `name`. The Levenshtein threshold is `len/3 + 1` (max 3) so short
183/// names get tighter matching — "for" shouldn't match "forgejo", but
184/// "forgeo" should. Bypasses [`list_available`]'s service.toml parse so
185/// we don't fail to suggest just because a sibling service has a
186/// malformed file: directory names alone are enough to compare.
187///
188/// Only called from [`find_service`] — `remove`/`config`/`test` errors
189/// already list or imply the small candidate set, so adding fuzzy
190/// suggestions there would be polish without payoff.
191fn 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
210/// Standalone iterative Levenshtein distance — case-insensitive so
211/// "Forgejo" vs "forgejo" doesn't add a phantom edit. No dependency,
212/// runs in O(n×m) time on rolling vectors.
213fn 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
239/// Render the trailing " — did you mean 'X'?" hint used by
240/// [`Error::ServiceNotFound`]. Empty when there are no suggestions, so
241/// users with truly unique typos don't see a stray prompt.
242pub 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); // substitution
258        assert_eq!(levenshtein("seafil", "seafile"), 1); // insertion
259        assert_eq!(levenshtein("seafiles", "seafile"), 1); // deletion
260        assert_eq!(levenshtein("SEAFILE", "seafile"), 0); // case-insensitive
261    }
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}