diff --git a/src/pkg.rs b/src/pkg.rs index 1e6ec8b6..f4254fe6 100644 --- a/src/pkg.rs +++ b/src/pkg.rs @@ -1,23 +1,30 @@ //! Package registry: `ilo add /[@]` / `ilo update`. //! -//! Cache layout: `~/.ilo/pkgs///` +//! Cache layout: `~/.ilo/pkgs////` //! Lockfile: `ilo.lock` in the project root (current working directory). //! //! The lock file is a plain-text tabular format — one resolved package per line: //! //! ``` //! # ilo.lock — generated by `ilo add`; commit to source control -//! owner/repo https://github.com/owner/repo +//! github:owner/repo https://github.com/owner/repo //! ``` //! //! Columns are tab-separated. Lines starting with `#` are comments. //! +//! ## Supported host prefixes +//! +//! - `github:org/repo` → https://github.com/org/repo (also bare `org/repo`) +//! - `gitlab:org/repo` → https://gitlab.com/org/repo +//! - `codeberg:user/repo`→ https://codeberg.org/user/repo +//! - `https://...` → used verbatim +//! //! ## `use "owner/repo"` resolution //! //! When `resolve_imports` sees a `use` path whose first component contains no //! `.` (i.e. it looks like `owner/repo` not `./local.ilo`), it delegates to -//! `pkg_dir_for` which returns `~/.ilo/pkgs///`. The resolver -//! then looks for an `index.ilo` inside that directory and merges it. +//! `resolve_pkg_path` which returns `~/.ilo/pkgs////`. The +//! resolver then looks for an `index.ilo` inside that directory and merges it. //! //! If the directory does not exist the resolver emits `ILO-P017` with a hint //! to run `ilo add owner/repo`. @@ -26,39 +33,160 @@ use semver::{Version, VersionReq}; use std::path::{Path, PathBuf}; use std::process::Command; +// ── host registry ────────────────────────────────────────────────────────────── + +/// A recognised git hosting provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Host { + GitHub, + GitLab, + Codeberg, + /// A raw HTTPS base URL, e.g. `https://git.example.com`. + Custom(String), +} + +impl Host { + /// The short name used as a cache-directory component and lockfile prefix. + pub fn name(&self) -> &str { + match self { + Host::GitHub => "github", + Host::GitLab => "gitlab", + Host::Codeberg => "codeberg", + Host::Custom(url) => url.as_str(), + } + } + + /// The HTTPS base URL (no trailing slash). + pub fn base_url(&self) -> String { + match self { + Host::GitHub => "https://github.com".to_string(), + Host::GitLab => "https://gitlab.com".to_string(), + Host::Codeberg => "https://codeberg.org".to_string(), + Host::Custom(url) => url.clone(), + } + } + + /// Build the clone URL for `owner/repo` on this host. + pub fn clone_url(&self, owner: &str, repo: &str) -> String { + format!("{}/{}/{}.git", self.base_url(), owner, repo) + } + + /// Build the canonical web URL (no `.git`) for the lockfile. + pub fn web_url(&self, owner: &str, repo: &str) -> String { + format!("{}/{}/{}", self.base_url(), owner, repo) + } +} + +/// Parse an optional `host:` prefix from a spec string. +/// +/// Returns `(Host, rest_of_spec)`. If no recognised prefix is found the host +/// defaults to `Host::GitHub` and the full string is returned as `rest`. +fn parse_host_prefix(spec: &str) -> (Host, &str) { + // Full HTTPS URLs are handled elsewhere; skip them here. + if spec.starts_with("https://") || spec.starts_with("http://") { + return (Host::GitHub, spec); // will be processed by URL strip logic + } + if let Some(rest) = spec.strip_prefix("github:") { + return (Host::GitHub, rest); + } + if let Some(rest) = spec.strip_prefix("gitlab:") { + return (Host::GitLab, rest); + } + if let Some(rest) = spec.strip_prefix("codeberg:") { + return (Host::Codeberg, rest); + } + // No prefix — default to GitHub. + (Host::GitHub, spec) +} + // ── public helpers ───────────────────────────────────────────────────────────── /// Return the cache directory for a package. Creates `~/.ilo/pkgs/` if needed. /// Returns `None` when the home directory cannot be determined. -pub fn pkg_dir_for(owner: &str, repo: &str) -> Option { +pub fn pkg_dir_for(host: &Host, owner: &str, repo: &str) -> Option { let home = home_dir()?; - Some(home.join(".ilo").join("pkgs").join(owner).join(repo)) + Some( + home.join(".ilo") + .join("pkgs") + .join(host.name()) + .join(owner) + .join(repo), + ) } -/// Parse `owner/repo[@ref]` into `(owner, repo, Option)`. -/// Returns `None` when the input is not in a recognised form. -pub fn parse_package_spec(spec: &str) -> Option<(&str, &str, Option<&str>)> { - // Strip a leading `https://github.com/` if someone pastes the URL. - let spec = spec.strip_prefix("https://github.com/").unwrap_or(spec); +/// Parsed package specification returned by `parse_package_spec`. +#[derive(Debug, PartialEq)] +pub struct PackageSpec { + pub host: Host, + pub owner: String, + pub repo: String, + pub git_ref: Option, +} - let (slug, git_ref) = if let Some((s, r)) = spec.split_once('@') { - (s, Some(r)) - } else { - (spec, None) - }; +/// Parse a package spec into its components. +/// +/// Accepted forms: +/// - `owner/repo` +/// - `owner/repo@ref` +/// - `github:owner/repo[@ref]` +/// - `gitlab:owner/repo[@ref]` +/// - `codeberg:owner/repo[@ref]` +/// - `https://github.com/owner/repo` (full URL, host inferred) +/// - `https://gitlab.com/owner/repo` +/// - `https://codeberg.org/owner/repo` +/// +/// Returns `None` when the input is not in a recognised form. +pub fn parse_package_spec(spec: &str) -> Option { + // ── full HTTPS URL ──────────────────────────────────────────────────────── + if spec.starts_with("https://") || spec.starts_with("http://") { + let (host, rest) = if let Some(r) = spec + .strip_prefix("https://github.com/") + .or_else(|| spec.strip_prefix("http://github.com/")) + { + (Host::GitHub, r) + } else if let Some(r) = spec + .strip_prefix("https://gitlab.com/") + .or_else(|| spec.strip_prefix("http://gitlab.com/")) + { + (Host::GitLab, r) + } else if let Some(r) = spec + .strip_prefix("https://codeberg.org/") + .or_else(|| spec.strip_prefix("http://codeberg.org/")) + { + (Host::Codeberg, r) + } else { + return None; // unknown host URL + }; + let (slug, git_ref) = split_ref(rest); + let (owner, repo) = slug.split_once('/')?; + if owner.is_empty() || repo.is_empty() || repo.contains('/') { + return None; + } + return Some(PackageSpec { + host, + owner: owner.to_string(), + repo: repo.to_string(), + git_ref: git_ref.map(str::to_string), + }); + } + // ── host-prefixed or bare spec ──────────────────────────────────────────── + let (host, rest) = parse_host_prefix(spec); + let (slug, git_ref) = split_ref(rest); let (owner, repo) = slug.split_once('/')?; - - // Must have exactly one `/` in the slug. if owner.is_empty() || repo.is_empty() || repo.contains('/') { return None; } - - Some((owner, repo, git_ref)) + Some(PackageSpec { + host, + owner: owner.to_string(), + repo: repo.to_string(), + git_ref: git_ref.map(str::to_string), + }) } /// Return true when `path` looks like a package reference (`owner/repo` or -/// `owner/repo/sub/path.ilo`) rather than a local file path. +/// `host:owner/repo` or `owner/repo/sub/path.ilo`) rather than a local file. /// /// Heuristic: the first component does not start with `.` or `/` and contains /// no `.` (file extensions), which distinguishes it from `./lib.ilo` or @@ -67,38 +195,62 @@ pub fn is_pkg_path(path: &str) -> bool { if path.starts_with('.') || path.starts_with('/') { return false; } + // Strip a recognised host prefix before checking the first component. + let stripped = if let Some(r) = path + .strip_prefix("github:") + .or_else(|| path.strip_prefix("gitlab:")) + .or_else(|| path.strip_prefix("codeberg:")) + { + r + } else { + path + }; // Split off first component and check it looks like `owner` (no dots, no @). - let first = path.split('/').next().unwrap_or(""); + let first = stripped.split('/').next().unwrap_or(""); !first.is_empty() && !first.contains('.') } /// Resolve a package `use` path to an absolute filesystem path. /// -/// `path` is the string from the `use` statement, e.g. `"myorg/helpers"` or -/// `"myorg/helpers/utils.ilo"`. When the path has no `.ilo` suffix the -/// resolver appends `/index.ilo`. +/// `path` is the string from the `use` statement, e.g. `"myorg/helpers"`, +/// `"gitlab:myorg/helpers"`, or `"myorg/helpers/utils.ilo"`. When the path +/// has no `.ilo` suffix the resolver appends `/index.ilo`. /// /// Returns `Err(msg)` with a user-facing message when the package is not /// installed (so the caller can wrap it in `ILO-P017`). pub fn resolve_pkg_path(path: &str) -> Result { - // Strip leading slash/dots (already ruled out by is_pkg_path, but be safe). - let (owner, rest) = path + // Strip a recognised host prefix. + let (host, rest) = if let Some(r) = path.strip_prefix("github:") { + (Host::GitHub, r) + } else if let Some(r) = path.strip_prefix("gitlab:") { + (Host::GitLab, r) + } else if let Some(r) = path.strip_prefix("codeberg:") { + (Host::Codeberg, r) + } else { + (Host::GitHub, path) + }; + + let (owner, rest2) = rest .split_once('/') .ok_or_else(|| format!("package path '{}' is not in owner/repo form", path))?; - let (repo, sub) = if let Some((r, s)) = rest.split_once('/') { + let (repo, sub) = if let Some((r, s)) = rest2.split_once('/') { (r, Some(s)) } else { - (rest, None) + (rest2, None) }; - let cache_dir = pkg_dir_for(owner, repo) + let cache_dir = pkg_dir_for(&host, owner, repo) .ok_or_else(|| "could not determine home directory for package cache".to_string())?; if !cache_dir.exists() { + let hint_spec = if matches!(host, Host::GitHub) { + format!("{owner}/{repo}") + } else { + format!("{}:{owner}/{repo}", host.name()) + }; return Err(format!( - "package '{}/{repo}' is not installed — run `ilo add {owner}/{repo}` first", - owner + "package '{hint_spec}' is not installed — run `ilo add {hint_spec}` first" )); } @@ -200,24 +352,38 @@ pub fn resolve_semver_ref(url: &str, constraint_str: &str) -> Result i32 { - let Some((owner, repo, git_ref)) = parse_package_spec(spec) else { + let Some(pkg) = parse_package_spec(spec) else { eprintln!( "error: '{}' is not a valid package spec.\n\ - Expected: owner/repo or owner/repo@ref", + Expected: owner/repo or host:owner/repo or owner/repo@ref\n\ + Supported hosts: github (default), gitlab, codeberg", spec ); return 1; }; - let url = format!("https://github.com/{owner}/{repo}.git"); + let PackageSpec { + host, + owner, + repo, + git_ref, + } = pkg; + + let url = host.clone_url(&owner, &repo); // Resolve semver constraints to a concrete tag before cloning. - // `resolved_owned` keeps the heap allocation alive for the lifetime of - // the borrow in `git_ref`. - let resolved_owned: Option = match git_ref { + let resolved_ref: Option = match git_ref.as_deref() { Some(r) if is_semver_constraint(r) => { match resolve_semver_ref(&url, r) { Ok(tag) => { @@ -232,12 +398,12 @@ pub fn cmd_add(spec: &str) -> i32 { } _ => None, }; - let git_ref: &str = match &resolved_owned { + let git_ref_str: &str = match &resolved_ref { Some(tag) => tag.as_str(), - None => git_ref.unwrap_or("HEAD"), + None => git_ref.as_deref().unwrap_or("HEAD"), }; - let Some(dest) = pkg_dir_for(owner, repo) else { + let Some(dest) = pkg_dir_for(&host, &owner, &repo) else { eprintln!("error: could not determine home directory"); return 1; }; @@ -272,7 +438,7 @@ pub fn cmd_add(spec: &str) -> i32 { "--depth=1", "--single-branch", "--branch", - git_ref, + git_ref_str, &url, dest.to_str().unwrap_or(""), ]) @@ -298,14 +464,14 @@ pub fn cmd_add(spec: &str) -> i32 { return 1; } } - if git_ref != "HEAD" { + if git_ref_str != "HEAD" { let checkout = Command::new("git") - .args(["-C", dest.to_str().unwrap_or(""), "checkout", git_ref]) + .args(["-C", dest.to_str().unwrap_or(""), "checkout", git_ref_str]) .status(); if !checkout.map(|s| s.success()).unwrap_or(false) { eprintln!( "error: could not checkout ref '{}' in {}/{}", - git_ref, owner, repo + git_ref_str, owner, repo ); return 1; } @@ -315,15 +481,14 @@ pub fn cmd_add(spec: &str) -> i32 { // Read resolved commit SHA. let sha = resolved_sha(&dest); + // The lockfile slug includes the host prefix (e.g. `github:owner/repo`). + let lock_slug = format!("{}:{}/{}", host.name(), owner, repo); + let web_url = host.web_url(&owner, &repo); + // Update ilo.lock. - update_lockfile( - owner, - repo, - &sha, - &format!("https://github.com/{owner}/{repo}"), - ); + update_lockfile(&lock_slug, &sha, &web_url); - println!("added {owner}/{repo} @ {sha}"); + println!("added {} @ {}", lock_slug, sha); println!(" cache: {}", dest.display()); 0 } @@ -379,8 +544,7 @@ fn read_lockfile() -> Vec { } /// Write or update the lockfile entry for a package. -fn update_lockfile(owner: &str, repo: &str, sha: &str, url: &str) { - let slug = format!("{owner}/{repo}"); +fn update_lockfile(slug: &str, sha: &str, url: &str) { let path = Path::new("ilo.lock"); let existing = if path.exists() { @@ -398,7 +562,7 @@ fn update_lockfile(owner: &str, repo: &str, sha: &str, url: &str) { .map(|l| { if !l.starts_with('#') { let cols: Vec<&str> = l.splitn(2, '\t').collect(); - if cols.first().copied() == Some(slug.as_str()) { + if cols.first().copied() == Some(slug) { found = true; return new_line.clone(); } @@ -422,6 +586,14 @@ fn update_lockfile(owner: &str, repo: &str, sha: &str, url: &str) { // ── helpers ──────────────────────────────────────────────────────────────────── +fn split_ref(s: &str) -> (&str, Option<&str>) { + if let Some((slug, r)) = s.split_once('@') { + (slug, Some(r)) + } else { + (s, None) + } +} + fn resolved_sha(repo_dir: &Path) -> String { Command::new("git") .args(["-C", repo_dir.to_str().unwrap_or("."), "rev-parse", "HEAD"]) @@ -442,22 +614,80 @@ fn home_dir() -> Option { mod tests { use super::*; + // ── parse_package_spec ──────────────────────────────────────────────────── + #[test] fn parse_simple_spec() { - let r = parse_package_spec("myorg/helpers"); - assert_eq!(r, Some(("myorg", "helpers", None))); + let r = parse_package_spec("myorg/helpers").unwrap(); + assert_eq!(r.host, Host::GitHub); + assert_eq!(r.owner, "myorg"); + assert_eq!(r.repo, "helpers"); + assert_eq!(r.git_ref, None); } #[test] fn parse_spec_with_ref() { - let r = parse_package_spec("myorg/helpers@v1.2"); - assert_eq!(r, Some(("myorg", "helpers", Some("v1.2")))); + let r = parse_package_spec("myorg/helpers@v1.2").unwrap(); + assert_eq!(r.host, Host::GitHub); + assert_eq!(r.owner, "myorg"); + assert_eq!(r.repo, "helpers"); + assert_eq!(r.git_ref, Some("v1.2".to_string())); + } + + #[test] + fn parse_explicit_github_prefix() { + let r = parse_package_spec("github:myorg/helpers").unwrap(); + assert_eq!(r.host, Host::GitHub); + assert_eq!(r.owner, "myorg"); + assert_eq!(r.repo, "helpers"); + } + + #[test] + fn parse_gitlab_prefix() { + let r = parse_package_spec("gitlab:myorg/myrepo").unwrap(); + assert_eq!(r.host, Host::GitLab); + assert_eq!(r.owner, "myorg"); + assert_eq!(r.repo, "myrepo"); + assert_eq!(r.git_ref, None); + } + + #[test] + fn parse_gitlab_prefix_with_ref() { + let r = parse_package_spec("gitlab:myorg/myrepo@main").unwrap(); + assert_eq!(r.host, Host::GitLab); + assert_eq!(r.git_ref, Some("main".to_string())); + } + + #[test] + fn parse_codeberg_prefix() { + let r = parse_package_spec("codeberg:user/repo").unwrap(); + assert_eq!(r.host, Host::Codeberg); + assert_eq!(r.owner, "user"); + assert_eq!(r.repo, "repo"); } #[test] fn parse_github_url() { - let r = parse_package_spec("https://github.com/myorg/helpers"); - assert_eq!(r, Some(("myorg", "helpers", None))); + let r = parse_package_spec("https://github.com/myorg/helpers").unwrap(); + assert_eq!(r.host, Host::GitHub); + assert_eq!(r.owner, "myorg"); + assert_eq!(r.repo, "helpers"); + } + + #[test] + fn parse_gitlab_url() { + let r = parse_package_spec("https://gitlab.com/myorg/helpers").unwrap(); + assert_eq!(r.host, Host::GitLab); + assert_eq!(r.owner, "myorg"); + assert_eq!(r.repo, "helpers"); + } + + #[test] + fn parse_codeberg_url() { + let r = parse_package_spec("https://codeberg.org/user/repo").unwrap(); + assert_eq!(r.host, Host::Codeberg); + assert_eq!(r.owner, "user"); + assert_eq!(r.repo, "repo"); } #[test] @@ -466,12 +696,39 @@ mod tests { assert!(parse_package_spec("").is_none()); } + // ── host URLs ───────────────────────────────────────────────────────────── + + #[test] + fn host_clone_urls() { + assert_eq!( + Host::GitHub.clone_url("org", "repo"), + "https://github.com/org/repo.git" + ); + assert_eq!( + Host::GitLab.clone_url("org", "repo"), + "https://gitlab.com/org/repo.git" + ); + assert_eq!( + Host::Codeberg.clone_url("user", "repo"), + "https://codeberg.org/user/repo.git" + ); + } + + // ── is_pkg_path ─────────────────────────────────────────────────────────── + #[test] fn is_pkg_path_true_for_owner_repo() { assert!(is_pkg_path("myorg/helpers")); assert!(is_pkg_path("myorg/helpers/utils.ilo")); } + #[test] + fn is_pkg_path_true_for_host_prefixed() { + assert!(is_pkg_path("gitlab:myorg/helpers")); + assert!(is_pkg_path("codeberg:user/repo")); + assert!(is_pkg_path("github:myorg/helpers")); + } + #[test] fn is_pkg_path_false_for_local() { assert!(!is_pkg_path("./lib.ilo")); @@ -506,4 +763,22 @@ mod tests { // (Local ilo files must use a leading `./` to be unambiguous.) assert!(is_pkg_path("relative/no-ext-dir")); } + + // ── pkg_dir_for ─────────────────────────────────────────────────────────── + + #[test] + fn pkg_dir_includes_host() { + let dir = pkg_dir_for(&Host::GitLab, "org", "repo").unwrap(); + let s = dir.to_string_lossy(); + assert!(s.contains("gitlab"), "expected 'gitlab' in path, got {}", s); + assert!(s.contains("org")); + assert!(s.contains("repo")); + } + + #[test] + fn pkg_dir_github_default() { + let dir = pkg_dir_for(&Host::GitHub, "org", "repo").unwrap(); + let s = dir.to_string_lossy(); + assert!(s.contains("github"), "expected 'github' in path, got {}", s); + } }