Skip to main content

zsh/extensions/pkg/
resolver.rs

1//! Resolve a user-supplied source spec into a staged plugin directory.
2//! Adapted from strykelang's `pkg/resolver.rs`, trimmed to the three source
3//! forms zshrs plugins ship as (no semver/registry graph — global model).
4//!
5//! Source forms accepted by `znative add <SOURCE>`:
6//! - `owner/repo` or `github:owner/repo` → `git clone https://github.com/owner/repo`
7//! - `git+URL`, or any URL ending in `.git` → `git clone URL`
8//! - `path:DIR`, an absolute path, or `./rel`, `../rel` → a local directory
9//!
10//! `@REF` may be appended to a git/github source to pin a branch/tag/commit
11//! (`owner/repo@v1.2.0`). The resolver clones into `$ZSHRS_HOME/pkg/git/` and
12//! returns the working tree; the caller copies the loadable subset into the
13//! content-addressed store.
14
15use std::path::{Path, PathBuf};
16use std::process::Command;
17
18use super::store::Store;
19use super::{PkgError, PkgResult};
20
21/// A staged source ready to install into the store.
22pub struct Staged {
23    /// Working directory containing the plugin tree.
24    pub dir: PathBuf,
25    /// Inferred plugin name (repo/dir basename).
26    pub name: String,
27    /// Provenance label recorded in the index: `github:owner/repo`,
28    /// `git+URL`, or `path+file://DIR`.
29    pub source: String,
30}
31
32/// Resolve `spec` into a [`Staged`] tree. Clones (git/github) land under
33/// `store.git_dir()`; local paths are used in place.
34pub fn resolve(spec: &str, store: &Store) -> PkgResult<Staged> {
35    let (base, git_ref) = split_ref(spec);
36
37    // Local path forms.
38    if let Some(p) = local_path(base) {
39        let dir = p
40            .canonicalize()
41            .map_err(|e| PkgError::Resolve(format!("path {}: {}", p.display(), e)))?;
42        if !dir.is_dir() {
43            return Err(PkgError::Resolve(format!(
44                "path {} is not a directory",
45                dir.display()
46            )));
47        }
48        let name = basename(&dir);
49        let source = format!("path+file://{}", dir.display());
50        return Ok(Staged { dir, name, source });
51    }
52
53    // Git / GitHub forms.
54    let (url, label, name) = git_url(base)?;
55    store
56        .ensure_layout()
57        .map_err(|e| PkgError::Resolve(e.to_string()))?;
58    let dir = store.git_dir().join(&name);
59    if dir.exists() {
60        std::fs::remove_dir_all(&dir)
61            .map_err(|e| PkgError::Io(format!("clear {}: {}", dir.display(), e)))?;
62    }
63    git_clone(&url, &dir, git_ref)?;
64    Ok(Staged {
65        dir,
66        name,
67        // Record the pinned ref in the source so `update` re-fetches the SAME
68        // version and `load owner/repo@REF` matches only that pin.
69        source: label_with_ref(label, git_ref),
70    })
71}
72
73/// Append `@REF` to a provenance label when a version/ref was pinned, so the
74/// recorded source round-trips back through `resolve`/`split_ref`.
75fn label_with_ref(label: String, git_ref: Option<&str>) -> String {
76    match git_ref {
77        Some(r) => format!("{}@{}", label, r),
78        None => label,
79    }
80}
81
82/// The provenance label a `spec` WOULD receive, computed WITHOUT cloning or
83/// network access. Used by `znative load <spec>` to check whether a source is
84/// already installed (the index keys on this label, since a repo's basename
85/// often differs from its `znative.toml` plugin name — e.g. `zshrs-forgit` →
86/// `forgit`). Returns `None` for a bare plugin name (not a source form).
87pub fn source_label(spec: &str) -> Option<String> {
88    let (base, git_ref) = split_ref(spec);
89    if let Some(p) = local_path(base) {
90        // Match the `path+file://<canonical>` the installer records.
91        let dir = p.canonicalize().ok()?;
92        return Some(format!("path+file://{}", dir.display()));
93    }
94    git_url(base)
95        .ok()
96        .map(|(_url, label, _name)| label_with_ref(label, git_ref))
97}
98
99/// Split a trailing `@REF` (branch/tag/commit) off a spec. Only splits on the
100/// LAST `@` so `git@host:...` SSH URLs keep their `@`.
101fn split_ref(spec: &str) -> (&str, Option<&str>) {
102    // A leading path or scheme with an early `@` (SSH) should not be treated as
103    // a ref; only accept `@` after the last `/`.
104    if let Some(at) = spec.rfind('@') {
105        let after_slash = spec.rfind('/').map(|s| at > s).unwrap_or(true);
106        if after_slash && at + 1 < spec.len() {
107            return (&spec[..at], Some(&spec[at + 1..]));
108        }
109    }
110    (spec, None)
111}
112
113/// Recognize local-path forms; returns the path when `spec` is one.
114fn local_path(spec: &str) -> Option<PathBuf> {
115    if let Some(rest) = spec.strip_prefix("path:") {
116        return Some(PathBuf::from(rest));
117    }
118    if spec.starts_with('/')
119        || spec.starts_with("./")
120        || spec.starts_with("../")
121        || spec.starts_with('~')
122    {
123        let expanded = if let Some(rest) = spec.strip_prefix("~/") {
124            if let Some(home) = std::env::var_os("HOME") {
125                PathBuf::from(home).join(rest)
126            } else {
127                PathBuf::from(spec)
128            }
129        } else {
130            PathBuf::from(spec)
131        };
132        return Some(expanded);
133    }
134    None
135}
136
137/// Map a non-local spec to `(clone_url, provenance_label, name)`.
138fn git_url(spec: &str) -> PkgResult<(String, String, String)> {
139    if let Some(rest) = spec.strip_prefix("git+") {
140        let name = repo_basename(rest);
141        return Ok((rest.to_string(), format!("git+{}", rest), name));
142    }
143    if let Some(rest) = spec.strip_prefix("github:") {
144        let url = format!("https://github.com/{}", rest.trim_end_matches(".git"));
145        let name = repo_basename(&url);
146        return Ok((
147            url,
148            format!("github:{}", rest.trim_end_matches(".git")),
149            name,
150        ));
151    }
152    if spec.ends_with(".git") || spec.contains("://") {
153        let name = repo_basename(spec);
154        return Ok((spec.to_string(), format!("git+{}", spec), name));
155    }
156    // `owner/repo` shorthand → GitHub.
157    if spec.split('/').count() == 2 && !spec.contains(' ') {
158        let owner_repo = spec.trim_end_matches(".git");
159        let url = format!("https://github.com/{}", owner_repo);
160        let name = repo_basename(&url);
161        return Ok((url, format!("github:{}", owner_repo), name));
162    }
163    Err(PkgError::Resolve(format!(
164        "unrecognized source '{}': expected owner/repo, github:owner/repo, \
165         git+URL, or a local path",
166        spec
167    )))
168}
169
170/// `git clone --depth 1 [--branch REF] URL DIR` — shallow for speed.
171fn git_clone(url: &str, dir: &Path, git_ref: Option<&str>) -> PkgResult<()> {
172    let mut cmd = Command::new("git");
173    cmd.arg("clone").arg("--depth").arg("1");
174    if let Some(r) = git_ref {
175        cmd.arg("--branch").arg(r);
176    }
177    cmd.arg(url).arg(dir);
178    let out = cmd
179        .output()
180        .map_err(|e| PkgError::Resolve(format!("git clone: {} (is git installed?)", e)))?;
181    if !out.status.success() {
182        // Retry without --branch: a REF that's a commit sha can't be used with
183        // `--branch` on a shallow clone. Fall back to a full clone + checkout.
184        if git_ref.is_some() {
185            return git_clone_checkout(url, dir, git_ref.unwrap());
186        }
187        return Err(PkgError::Resolve(format!(
188            "git clone {} failed: {}",
189            url,
190            String::from_utf8_lossy(&out.stderr).trim()
191        )));
192    }
193    Ok(())
194}
195
196/// Full clone + `git checkout REF` — the fallback when a shallow `--branch`
197/// clone can't reach an arbitrary commit.
198fn git_clone_checkout(url: &str, dir: &Path, git_ref: &str) -> PkgResult<()> {
199    if dir.exists() {
200        let _ = std::fs::remove_dir_all(dir);
201    }
202    let out = Command::new("git")
203        .arg("clone")
204        .arg(url)
205        .arg(dir)
206        .output()
207        .map_err(|e| PkgError::Resolve(format!("git clone: {}", e)))?;
208    if !out.status.success() {
209        return Err(PkgError::Resolve(format!(
210            "git clone {} failed: {}",
211            url,
212            String::from_utf8_lossy(&out.stderr).trim()
213        )));
214    }
215    let out = Command::new("git")
216        .current_dir(dir)
217        .arg("checkout")
218        .arg(git_ref)
219        .output()
220        .map_err(|e| PkgError::Resolve(format!("git checkout: {}", e)))?;
221    if !out.status.success() {
222        return Err(PkgError::Resolve(format!(
223            "git checkout {} failed: {}",
224            git_ref,
225            String::from_utf8_lossy(&out.stderr).trim()
226        )));
227    }
228    Ok(())
229}
230
231/// Basename of a directory path, sans trailing separators.
232fn basename(p: &Path) -> String {
233    p.file_name()
234        .map(|s| s.to_string_lossy().into_owned())
235        .unwrap_or_else(|| "plugin".into())
236}
237
238/// Repo name from a clone URL: strip `.git`, take the last path segment.
239fn repo_basename(url: &str) -> String {
240    url.trim_end_matches('/')
241        .trim_end_matches(".git")
242        .rsplit(['/', ':'])
243        .next()
244        .unwrap_or("plugin")
245        .to_string()
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn split_ref_only_after_last_slash() {
254        assert_eq!(split_ref("o/r@v1"), ("o/r", Some("v1")));
255        assert_eq!(split_ref("o/r"), ("o/r", None));
256        // SSH URL @ must not split.
257        assert_eq!(
258            split_ref("git@github.com:o/r.git"),
259            ("git@github.com:o/r.git", None)
260        );
261    }
262
263    #[test]
264    fn git_url_forms() {
265        let (u, l, n) = git_url("owner/repo").unwrap();
266        assert_eq!(u, "https://github.com/owner/repo");
267        assert_eq!(l, "github:owner/repo");
268        assert_eq!(n, "repo");
269        let (u, _, n) = git_url("github:a/b").unwrap();
270        assert_eq!(u, "https://github.com/a/b");
271        assert_eq!(n, "b");
272        let (u, l, _) = git_url("git+https://x.com/y.git").unwrap();
273        assert_eq!(u, "https://x.com/y.git");
274        assert_eq!(l, "git+https://x.com/y.git");
275        assert!(git_url("not a source").is_err());
276    }
277
278    #[test]
279    fn local_path_forms() {
280        assert!(local_path("path:/tmp/x").is_some());
281        assert!(local_path("/abs").is_some());
282        assert!(local_path("./rel").is_some());
283        assert!(local_path("owner/repo").is_none());
284    }
285}