Skip to main content

zoi_cli/pkg/
repo_install.rs

1use std::collections::{BTreeMap, HashSet};
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use anyhow::{Result, anyhow};
6use colored::Colorize;
7use tempfile::TempDir;
8use zoi_core::types::SourceType;
9use zoi_project::config::{ImportSpec, PackageExportSpec};
10
11/// Parsed repository URL and optional package selector.
12#[derive(Debug)]
13struct RepoSpec {
14    /// Git repository URL.
15    url: String,
16    /// Optional package export selector.
17    selector: Option<String>
18}
19
20/// Owns cloned repository trees for the lifetime of an installation.
21pub struct RepoWorkspace {
22    /// Temporary directories containing cloned repositories.
23    roots: Vec<TempDir>,
24    /// Absolute package definition paths selected during preparation.
25    sources: Vec<String>,
26    /// Exact top-level repository commit.
27    revision: String
28}
29
30impl RepoWorkspace {
31    /// Clones a repository and resolves its selected package definition.
32    ///
33    /// # Errors
34    ///
35    /// Returns an error when cloning, parsing the repository `zoi.lua`,
36    /// resolving imports, or selecting the requested package fails.
37    pub fn prepare(spec: &str) -> Result<Self> {
38        let spec = parse_repo_spec(spec)?;
39        let root = tempfile::Builder::new().prefix("zoi-repo-").tempdir()?;
40        clone_repository(&spec.url, root.path(), None)?;
41        let revision = repository_revision(root.path())?;
42        let manifest_path = root.path().join("zoi.lua");
43        let mut workspace = Self {
44            roots: vec![root],
45            sources: Vec::new(),
46            revision
47        };
48        let root_path = workspace
49            .roots
50            .first()
51            .map(|root| root.path().to_path_buf())
52            .ok_or_else(|| anyhow!("Repository workspace is unavailable"))?;
53        let source = workspace.resolve_repository_source(
54            &root_path,
55            &manifest_path,
56            spec.selector.as_deref(),
57            &mut HashSet::new()
58        )?;
59        workspace
60            .sources
61            .push(source.to_string_lossy().into_owned());
62        Ok(workspace)
63    }
64
65    /// Returns the absolute package definition paths selected from the
66    /// repository.
67    #[must_use]
68    pub fn sources(&self) -> &[String] {
69        &self.sources
70    }
71
72    /// Returns the exact commit used for the top-level repository.
73    #[must_use]
74    pub fn revision(&self) -> &str {
75        &self.revision
76    }
77
78    /// Resolves a repository export while detecting recursive import cycles.
79    ///
80    /// # Errors
81    ///
82    /// Returns an error when the repository manifest is invalid, the selected
83    /// export is unavailable, or an import cycle is detected.
84    fn resolve_repository_source(
85        &mut self,
86        repository_root: &Path,
87        manifest_path: &Path,
88        selector: Option<&str>,
89        stack: &mut HashSet<String>
90    ) -> Result<PathBuf> {
91        let repository_key = repository_root.to_string_lossy().into_owned();
92        if !stack.insert(repository_key.clone()) {
93            return Err(anyhow!(
94                "Repository import cycle detected at '{}'",
95                manifest_path.display()
96            ));
97        }
98
99        let result = (|| {
100            let (imports, exports) =
101                zoi_project::lua_config::load_repo_zoi_lua(manifest_path)?;
102            let exports = exports.ok_or_else(|| {
103                anyhow!(
104                    "Repository {} declares no package. Add a top-level \
105                     package(...) call.",
106                    manifest_path.parent().unwrap_or(manifest_path).display()
107                )
108            })?;
109            let source = select_export(&exports, selector)?;
110            resolve_source(repository_root, &source, &imports, self, stack)
111        })();
112
113        stack.remove(&repository_key);
114        result
115    }
116}
117
118/// Clones a repository and optionally checks out a requested revision.
119///
120/// # Errors
121///
122/// Returns an error when the Git clone or checkout command fails.
123fn clone_repository(
124    url: &str,
125    destination: &Path,
126    revision: Option<&str>
127) -> Result<()> {
128    let output = Command::new("git")
129        .arg("clone")
130        .arg("--no-tags")
131        .arg(url)
132        .arg(destination)
133        .output()
134        .map_err(|error| anyhow!("Failed to run git clone: {error}"))?;
135    if !output.status.success() {
136        return Err(anyhow!(
137            "Failed to clone '{}': {}",
138            url,
139            String::from_utf8_lossy(&output.stderr).trim()
140        ));
141    }
142    if let Some(revision) = revision {
143        let output = Command::new("git")
144            .arg("-C")
145            .arg(destination)
146            .arg("checkout")
147            .arg("--detach")
148            .arg(revision)
149            .output()
150            .map_err(|error| anyhow!("Failed to run git checkout: {error}"))?;
151        if !output.status.success() {
152            return Err(anyhow!(
153                "Failed to check out import revision '{}': {}",
154                revision,
155                String::from_utf8_lossy(&output.stderr).trim()
156            ));
157        }
158    }
159    Ok(())
160}
161
162/// Reads the exact revision currently checked out in a repository.
163///
164/// # Errors
165///
166/// Returns an error when Git cannot read the repository revision.
167fn repository_revision(path: &Path) -> Result<String> {
168    let output = Command::new("git")
169        .arg("-C")
170        .arg(path)
171        .arg("rev-parse")
172        .arg("HEAD")
173        .output()
174        .map_err(|error| {
175            anyhow!("Failed to read repository revision: {error}")
176        })?;
177    if !output.status.success() {
178        return Err(anyhow!(
179            "Failed to read repository revision: {}",
180            String::from_utf8_lossy(&output.stderr).trim()
181        ));
182    }
183    Ok(String::from_utf8(output.stdout)?.trim().to_string())
184}
185
186/// Selects a package export from a repository manifest.
187///
188/// # Errors
189///
190/// Returns an error when the requested export is absent or the manifest has
191/// no unambiguous default export.
192fn select_export(
193    exports: &PackageExportSpec,
194    selector: Option<&str>
195) -> Result<String> {
196    if let Some(selector) = selector {
197        if let Some(main) = &exports.main
198            && (selector == "main" || exports.packages.is_empty())
199        {
200            return Ok(main.clone());
201        }
202        return exports.packages.get(selector).cloned().ok_or_else(|| {
203            let mut available = Vec::new();
204            if exports.main.is_some() {
205                available.push("main".to_string());
206            }
207            available.extend(exports.packages.keys().cloned());
208            anyhow!(
209                "Repository export '{selector}' was not found. Available: {}",
210                available.join(", ")
211            )
212        });
213    }
214
215    if let Some(main) = &exports.main {
216        return Ok(main.clone());
217    }
218    match exports.packages.len() {
219        1 => Ok(exports
220            .packages
221            .values()
222            .next()
223            .cloned()
224            .ok_or_else(|| anyhow!("Repository package export is empty"))?),
225        0 => Err(anyhow!("Repository declares no package export")),
226        _ => {
227            let names = exports
228                .packages
229                .keys()
230                .map(|name| format!("--repo <repository>:{name}"))
231                .collect::<Vec<_>>()
232                .join(", ");
233            Err(anyhow!(
234                "Repository declares multiple packages and no main export. \
235                 Select one with: {names}"
236            ))
237        }
238    }
239}
240
241/// Resolves an export into a package definition contained in the workspace.
242///
243/// # Errors
244///
245/// Returns an error when an import or local package source cannot be cloned,
246/// found, or safely resolved within the repository root.
247fn resolve_source(
248    repository_root: &Path,
249    source: &str,
250    imports: &BTreeMap<String, ImportSpec>,
251    workspace: &mut RepoWorkspace,
252    stack: &mut HashSet<String>
253) -> Result<PathBuf> {
254    if let Some((alias, selector)) = source.split_once(':')
255        && let Some(import) = imports.get(alias)
256    {
257        let destination = workspace
258            .roots
259            .first()
260            .map(TempDir::path)
261            .ok_or_else(|| anyhow!("Repository workspace is unavailable"))?
262            .join("imports")
263            .join(alias);
264        if destination.exists() {
265            std::fs::remove_dir_all(&destination)?;
266        }
267        if let Some(parent) = destination.parent() {
268            std::fs::create_dir_all(parent)?;
269        }
270        clone_repository(
271            &repository_url(&import.repo)?,
272            &destination,
273            import.rev.as_deref()
274        )?;
275        let manifest_path = destination.join("zoi.lua");
276        if !manifest_path.is_file() {
277            return Err(anyhow!(
278                "Imported repository '{}' has no zoi.lua",
279                import.repo
280            ));
281        }
282        return workspace.resolve_repository_source(
283            &destination,
284            &manifest_path,
285            Some(selector),
286            stack
287        );
288    }
289
290    let source = source.strip_prefix("zoi:").unwrap_or(source);
291    if source.starts_with("http://") || source.starts_with("https://") {
292        return Err(anyhow!(
293            "Remote package URLs in cloned repositories must be materialized \
294             as imports: {source}"
295        ));
296    }
297
298    let path = repository_root.join(source);
299    if path.is_file() {
300        return ensure_within(repository_root, &path);
301    }
302    if path.is_dir() {
303        let conventional = path.join(format!(
304            "{}.pkg.lua",
305            path.file_name().and_then(|name| name.to_str()).ok_or_else(
306                || anyhow!("Package directory has an invalid name")
307            )?
308        ));
309        if conventional.is_file() {
310            return ensure_within(repository_root, &conventional);
311        }
312        let mut candidates = std::fs::read_dir(&path)?
313            .filter_map(Result::ok)
314            .map(|entry| entry.path())
315            .filter(|candidate| {
316                candidate.is_file()
317                    && candidate
318                        .file_name()
319                        .and_then(|name| name.to_str())
320                        .is_some_and(|name| name.ends_with(".pkg.lua"))
321            })
322            .collect::<Vec<_>>();
323        candidates.sort();
324        return match candidates.as_slice() {
325            [candidate] => ensure_within(repository_root, candidate),
326            [] => Err(anyhow!(
327                "Package folder '{}' contains no .pkg.lua file",
328                path.display()
329            )),
330            _ => Err(anyhow!(
331                "Package folder '{}' is ambiguous; select one of: {}",
332                path.display(),
333                candidates
334                    .iter()
335                    .filter_map(|candidate| candidate.file_stem())
336                    .filter_map(|name| name.to_str())
337                    .collect::<Vec<_>>()
338                    .join(", ")
339            ))
340        };
341    }
342
343    let flat = repository_root.join(format!("{source}.pkg.lua"));
344    if flat.is_file() {
345        return ensure_within(repository_root, &flat);
346    }
347    let nested = repository_root
348        .join(source)
349        .join(format!("{source}.pkg.lua"));
350    if nested.is_file() {
351        return ensure_within(repository_root, &nested);
352    }
353
354    ensure_within(repository_root, &path)
355}
356
357/// Canonicalizes a package source and ensures it remains inside a repository.
358///
359/// # Errors
360///
361/// Returns an error when a path cannot be canonicalized or escapes the
362/// repository root.
363fn ensure_within(root: &Path, path: &Path) -> Result<PathBuf> {
364    let root = root.canonicalize().map_err(|error| {
365        anyhow!("Failed to resolve repository root: {error}")
366    })?;
367    let path = path.canonicalize().map_err(|error| {
368        anyhow!(
369            "Failed to resolve package source '{}': {error}",
370            path.display()
371        )
372    })?;
373    if !path.starts_with(&root) {
374        return Err(anyhow!(
375            "Package source '{}' escapes the repository root",
376            path.display()
377        ));
378    }
379    Ok(path)
380}
381
382/// Expands a provider-qualified repository specification into a Git URL.
383///
384/// # Errors
385///
386/// Returns an error when the repository provider is unknown.
387fn repository_url(spec: &str) -> Result<String> {
388    if spec.starts_with("http://")
389        || spec.starts_with("https://")
390        || spec.starts_with("file://")
391        || spec.starts_with("git@")
392        || Path::new(spec).is_absolute()
393    {
394        return Ok(spec.to_string());
395    }
396
397    let (provider, path) = match spec.split_once(':') {
398        Some((provider, path)) => (provider, path),
399        None => ("github", spec)
400    };
401    let base = match provider {
402        "gh" | "github" => "https://github.com/",
403        "gl" | "gitlab" => "https://gitlab.com/",
404        "cb" | "codeberg" => "https://codeberg.org/",
405        _ => return Err(anyhow!("Unknown repository provider: {provider}"))
406    };
407    Ok(format!("{base}{}.git", path.trim_end_matches('/')))
408}
409
410/// Parses a user repository specification into its URL and package selector.
411///
412/// # Errors
413///
414/// Returns an error when the provider or repository specification is invalid.
415fn parse_repo_spec(spec: &str) -> Result<RepoSpec> {
416    if spec.starts_with("http://")
417        || spec.starts_with("https://")
418        || spec.starts_with("file://")
419        || spec.starts_with("git@")
420    {
421        let (url, selector) = spec
422            .split_once('#')
423            .map_or((spec, None), |(url, selector)| {
424                (url, Some(selector.to_string()))
425            });
426        return Ok(RepoSpec {
427            url: url.to_string(),
428            selector
429        });
430    }
431
432    let (provider, remainder) = match spec.split_once(':') {
433        Some((
434            provider @ ("gh" | "github" | "gl" | "gitlab" | "cb" | "codeberg"),
435            remainder
436        )) => (provider, remainder),
437        Some((provider, _)) => {
438            return Err(anyhow!("Unknown repository provider: {provider}"));
439        }
440        None => ("github", spec)
441    };
442    let (path, selector) = match remainder.rsplit_once(':') {
443        Some((path, selector)) => (path, Some(selector.to_string())),
444        None => (remainder, None)
445    };
446    if path.is_empty() || selector.as_ref().is_some_and(String::is_empty) {
447        return Err(anyhow!("Invalid repository specification: {spec}"));
448    }
449
450    let base = match provider {
451        "gh" | "github" => "https://github.com/",
452        "gl" | "gitlab" => "https://gitlab.com/",
453        "cb" | "codeberg" => "https://codeberg.org/",
454        _ => unreachable!("provider was validated")
455    };
456    Ok(RepoSpec {
457        url: format!("{base}{}.git", path.trim_end_matches('/')),
458        selector
459    })
460}
461
462/// Confirms access to an untrusted repository source.
463///
464/// # Errors
465///
466/// Returns an error when the user declines the source.
467pub fn confirm_repository_source(spec: &str, yes: bool) -> Result<()> {
468    zoi_core::utils::confirm_untrusted_source(
469        &SourceType::GitRepo(spec.to_string()),
470        yes
471    )
472}
473
474/// Prints the selected repository source for CLI output.
475pub fn print_prepared_source(source: &str) {
476    println!("Using cloned package source: {}", source.cyan());
477}