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#[derive(Debug)]
13struct RepoSpec {
14 url: String,
16 selector: Option<String>
18}
19
20pub struct RepoWorkspace {
22 roots: Vec<TempDir>,
24 sources: Vec<String>,
26 revision: String
28}
29
30impl RepoWorkspace {
31 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 #[must_use]
68 pub fn sources(&self) -> &[String] {
69 &self.sources
70 }
71
72 #[must_use]
74 pub fn revision(&self) -> &str {
75 &self.revision
76 }
77
78 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
118fn 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
162fn 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
186fn 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
241fn 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
357fn 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
382fn 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
410fn 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
462pub 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
474pub fn print_prepared_source(source: &str) {
476 println!("Using cloned package source: {}", source.cyan());
477}