Skip to main content

cfgd_core/modules/
registry.rs

1//! Module registries — git repos with prescribed directory structure, plus
2//! `registry/module[@tag]` reference parsing and remote-module fetching.
3
4use std::collections::HashMap;
5use std::path::Path;
6
7use crate::config::ModuleRegistryEntry;
8use crate::errors::{ModuleError, Result};
9
10use super::LoadedModule;
11use super::git::{
12    GitSource, clone_repo, fetch_existing_repo, fetch_git_source, get_head_commit_sha,
13    git_cache_dir, is_git_source, open_repo, parse_git_source,
14};
15use super::loader::load_module;
16use super::lockfile::hash_module_contents;
17
18/// Check if a module name is a `registry/module[@tag]` reference.
19/// Returns true if it contains `/` but is not a git URL.
20pub fn is_registry_ref(name: &str) -> bool {
21    name.contains('/') && !is_git_source(name)
22}
23
24/// Parsed registry/module reference.
25pub struct RegistryRef {
26    pub registry: String,
27    pub module: String,
28    pub tag: Option<String>,
29}
30
31/// Parse `registry/module[@tag]` into components.
32/// Returns `None` if the input doesn't match the expected pattern.
33pub fn parse_registry_ref(input: &str) -> Option<RegistryRef> {
34    // Split on first `/` to get registry and remainder
35    let (registry, remainder) = input.split_once('/')?;
36    if registry.is_empty() || remainder.is_empty() {
37        return None;
38    }
39
40    // Split remainder on `@` for optional tag
41    let (module, tag) = match remainder.split_once('@') {
42        Some((m, t)) if !m.is_empty() && !t.is_empty() => (m.to_string(), Some(t.to_string())),
43        Some((_, _)) => return None, // empty module or tag
44        None => (remainder.to_string(), None),
45    };
46
47    Some(RegistryRef {
48        registry: registry.to_string(),
49        module,
50        tag,
51    })
52}
53
54/// Resolve a profile module reference to its lookup name.
55///
56/// Profiles can reference modules as:
57/// - `tmux` — local module (returns `"tmux"`)
58/// - `community/tmux` — remote module from registry (returns `"tmux"`)
59///
60/// The returned name is what to look up in the loaded modules HashMap.
61pub fn resolve_profile_module_name(profile_ref: &str) -> &str {
62    if is_registry_ref(profile_ref) {
63        profile_ref
64            .split_once('/')
65            .map(|(_, m)| m)
66            .unwrap_or(profile_ref)
67    } else {
68        profile_ref
69    }
70}
71
72/// Result of fetching a remote module — module + lockfile metadata.
73#[derive(Debug, Clone)]
74pub struct FetchedRemoteModule {
75    pub module: LoadedModule,
76    pub commit: String,
77    pub integrity: String,
78}
79
80/// Fetch a remote module from a git URL.
81///
82/// Validates that the URL has a pinned ref (tag or commit SHA).
83/// Branches are rejected for security (upstream push = code execution).
84pub fn fetch_remote_module(
85    url: &str,
86    cache_base: &Path,
87    printer: &crate::output::Printer,
88) -> Result<FetchedRemoteModule> {
89    let git_src = parse_git_source(url)?;
90
91    // Enforce pinned ref for remote modules — only tags (which may be semver tags or
92    // commit SHAs) are allowed. Branch refs (?ref=main) are rejected because upstream
93    // pushes would silently change the code that gets executed.
94    if git_src.git_ref.is_some() {
95        return Err(ModuleError::UnpinnedRemoteModule {
96            name: url.to_string(),
97        }
98        .into());
99    }
100    if git_src.tag.is_none() {
101        return Err(ModuleError::UnpinnedRemoteModule {
102            name: url.to_string(),
103        }
104        .into());
105    }
106
107    let local_path = fetch_git_source(&git_src, cache_base, "remote", printer)?;
108
109    // The repo root is the cache dir (before subdir), we need it for commit hash
110    let repo_dir = git_cache_dir(cache_base, &git_src.repo_url);
111    let commit = get_head_commit_sha(&repo_dir)?;
112
113    // Load the module from the fetched path
114    let module = load_module(&local_path)?;
115
116    // Compute integrity hash
117    let integrity = hash_module_contents(&local_path)?;
118
119    Ok(FetchedRemoteModule {
120        module,
121        commit,
122        integrity,
123    })
124}
125
126// ---------------------------------------------------------------------------
127// Module registries — git repos with prescribed directory structure
128// ---------------------------------------------------------------------------
129
130/// A discovered module within a registry repo.
131#[derive(Debug, Clone)]
132pub struct RegistryModule {
133    /// Module name (directory name under `modules/`).
134    pub name: String,
135    /// Description from the module's `module.yaml` metadata.
136    pub description: String,
137    /// Registry name (alias) this module belongs to.
138    pub registry: String,
139    /// Available per-module tags (`<module>/v1.0.0` format) in the repo.
140    pub tags: Vec<String>,
141}
142
143/// Extract the default registry name from a GitHub URL.
144/// `https://github.com/cfgd-community/modules.git` → `cfgd-community`
145pub fn extract_registry_name(url: &str) -> Option<String> {
146    // Handle https://github.com/org/repo(.git)
147    if let Some(rest) = url
148        .strip_prefix("https://github.com/")
149        .or_else(|| url.strip_prefix("http://github.com/"))
150    {
151        return rest.split('/').next().map(|s| s.to_string());
152    }
153    // Handle git@github.com:org/repo(.git)
154    if let Some(rest) = url.strip_prefix("git@github.com:") {
155        return rest.split('/').next().map(|s| s.to_string());
156    }
157    None
158}
159
160/// Fetch a module registry repo and discover available modules.
161///
162/// Scans the `modules/` directory for subdirectories containing `module.yaml`.
163/// Also collects per-module tags (matching `<module>/v*` pattern).
164pub fn fetch_registry_modules(
165    registry: &ModuleRegistryEntry,
166    cache_base: &Path,
167    printer: &crate::output::Printer,
168) -> Result<Vec<RegistryModule>> {
169    let git_src = GitSource {
170        repo_url: registry.url.clone(),
171        tag: None,
172        git_ref: None,
173        subdir: None,
174    };
175
176    let cache_dir = git_cache_dir(cache_base, &registry.url);
177
178    // Clone or fetch
179    if cache_dir.join(".git").exists() || cache_dir.join("HEAD").exists() {
180        fetch_existing_repo(&cache_dir, &git_src, &registry.name, printer)?;
181    } else {
182        clone_repo(&cache_dir, &git_src, &registry.name, printer)?;
183    }
184
185    let modules_dir = cache_dir.join("modules");
186    if !modules_dir.is_dir() {
187        return Err(ModuleError::SourceFetchFailed {
188            url: registry.url.clone(),
189            message: "registry repo has no modules/ directory".into(),
190        }
191        .into());
192    }
193
194    // Collect per-module tags from the repo
195    let module_tags = list_module_tags(&cache_dir, &registry.name)?;
196
197    // Scan modules/ for module directories
198    let mut found = Vec::new();
199    let entries = std::fs::read_dir(&modules_dir)?;
200    for entry in entries {
201        let entry = entry?;
202        let path = entry.path();
203        if !path.is_dir() {
204            continue;
205        }
206        let module_yaml = path.join("module.yaml");
207        if !module_yaml.exists() {
208            continue;
209        }
210        let mod_name = match path.file_name().and_then(|n| n.to_str()) {
211            Some(n) => n.to_string(),
212            None => continue,
213        };
214
215        // Read description from module.yaml metadata
216        let description = std::fs::read_to_string(&module_yaml)
217            .ok()
218            .and_then(|c| crate::config::parse_module(&c).ok())
219            .and_then(|doc| doc.metadata.description.clone())
220            .unwrap_or_default();
221
222        // Collect tags for this module
223        let tags = module_tags.get(&mod_name).cloned().unwrap_or_default();
224
225        found.push(RegistryModule {
226            name: mod_name,
227            description,
228            registry: registry.name.clone(),
229            tags,
230        });
231    }
232
233    found.sort_by(|a, b| a.name.cmp(&b.name));
234    Ok(found)
235}
236
237/// List per-module tags from a source repo.
238/// Tags follow the `<module>/<version>` convention (e.g., `tmux/v1.0.0`).
239/// Returns a map of module_name → sorted list of version tags.
240fn list_module_tags(repo_path: &Path, source_name: &str) -> Result<HashMap<String, Vec<String>>> {
241    let repo = open_repo(repo_path, source_name, "")?;
242    let tag_names = repo
243        .tag_names(None)
244        .map_err(|e| ModuleError::GitFetchFailed {
245            module: source_name.to_string(),
246            url: String::new(),
247            message: format!("cannot list tags: {e}"),
248        })?;
249    Ok(group_module_tags(tag_names.iter().flatten()))
250}
251
252/// Group git tag names that follow the `<module>/<version>` convention into a
253/// `HashMap<module, sorted versions>`. Tags without a `/` (or without anything
254/// after the first `/`) are silently dropped — the registry layout requires
255/// the prefix, so anything else is unrelated to module versioning.
256///
257/// Each module's tag list is sorted with `parse_loose_version` (best-effort
258/// semver) and falls back to lexicographic string compare for tags that
259/// don't parse as semver. The last element is therefore the highest version
260/// — matching the consumer expectation in `latest_module_version`.
261pub(super) fn group_module_tags<'a, I>(tag_names: I) -> HashMap<String, Vec<String>>
262where
263    I: IntoIterator<Item = &'a str>,
264{
265    let mut result: HashMap<String, Vec<String>> = HashMap::new();
266    for tag_name in tag_names {
267        if let Some((module, version)) = tag_name.split_once('/') {
268            result
269                .entry(module.to_string())
270                .or_default()
271                .push(version.to_string());
272        }
273    }
274    for tags in result.values_mut() {
275        tags.sort_by(|a, b| {
276            // `parse_loose_version` strips a leading `v` itself, so the
277            // registry convention `<module>/v<X.Y.Z>` sorts correctly here.
278            let av = crate::parse_loose_version(a);
279            let bv = crate::parse_loose_version(b);
280            match (av, bv) {
281                (Some(av), Some(bv)) => av.cmp(&bv),
282                _ => a.cmp(b),
283            }
284        });
285    }
286    result
287}
288
289/// Find the latest version for a module in a registry repo.
290/// Registry repo tags follow `<module>/<version>` convention; returns only the version part.
291pub fn latest_module_version(
292    registry: &ModuleRegistryEntry,
293    module_name: &str,
294    cache_base: &Path,
295) -> Result<Option<String>> {
296    let cache_dir = git_cache_dir(cache_base, &registry.url);
297    let tags = list_module_tags(&cache_dir, &registry.name)?;
298    Ok(tags.get(module_name).and_then(|t| t.last()).cloned())
299}