Skip to main content

stryke/pkg/
resolver.rs

1//! Resolution pipeline (RFC phases 1–6 + 9 git):
2//! - Path deps (`{ path = "../lib" }`) are read from the filesystem, hashed,
3//!   copied into the store, and pinned in the lockfile.
4//! - Workspace member deps work the same as path deps.
5//! - GitHub-release deps (`{ github = "OWNER/REPO" [, version = "..."] }`)
6//!   download the prebuilt release tarball for the host triple (auto-
7//!   detected, override via `STRYKE_TARGET`), SHA-256 verify it, and
8//!   extract into the store. This is the canonical path for binary-only
9//!   FFI packages (stryke-arrow, stryke-aws, ...) whose cdylib needs
10//!   platform libs at build time and can't be reproduced from a clone.
11//! - Git deps (`{ git = "https://...", tag|branch|rev = ... }`) are cloned
12//!   into `~/.stryke/git/`, the resolved commit is recorded in the lockfile,
13//!   and the working copy is installed through the same `install_dir_dep`
14//!   path as path deps. Source-only deps land here — e.g. a pure-stryke
15//!   library hosted on github without release tarballs, or a private
16//!   non-github git URL.
17//! - Registry (`http = "1.0"`) deps return a structured "not yet implemented"
18//!   error so the CLI surface is honest about what's wired today.
19//!
20//! When the registry / PubGrub semver / parallel fetch land (RFC phases 7-8),
21//! they slot in at [`Resolver::resolve`] without changing the lockfile shape.
22
23use std::collections::{BTreeMap, BTreeSet};
24use std::path::{Path, PathBuf};
25
26use super::lockfile::{integrity_for_directory, LockedPackage, Lockfile};
27use super::manifest::{DepSource, DepSpec, DetailedDep, Manifest};
28use super::store::Store;
29use super::{PkgError, PkgResult};
30
31/// Drives manifest → lockfile resolution against a [`Store`].
32pub struct Resolver<'a> {
33    /// The project's `stryke.toml`.
34    pub manifest: &'a Manifest,
35    /// Directory containing `stryke.toml` — path deps are resolved relative to this.
36    pub manifest_dir: &'a Path,
37    /// Backing global store (`~/.stryke/...`).
38    pub store: &'a Store,
39}
40
41/// One concrete dep edge after resolution. Drives lockfile entry generation.
42#[derive(Debug, Clone)]
43struct ResolvedDep {
44    name: String,
45    version: String,
46    source: String,
47    integrity: String,
48    /// `name@version` strings of transitive deps, sorted.
49    deps: Vec<String>,
50    /// Features enabled for this resolution.
51    features: Vec<String>,
52}
53
54/// Outcome of resolving one project's dep graph.
55#[derive(Debug, Clone)]
56pub struct ResolveOutcome {
57    /// Lockfile snapshot ready to write to disk.
58    pub lockfile: Lockfile,
59    /// `(name, version, store_path)` for every dep that was newly extracted
60    /// or already present in the store. Useful for `s install` reporting.
61    pub installed: Vec<(String, String, PathBuf)>,
62}
63
64impl<'a> Resolver<'a> {
65    /// Resolve the manifest's runtime + dev + group deps into a lockfile and
66    /// install all path/workspace deps into the store.
67    ///
68    /// Walks deps recursively: each path dep's own `stryke.toml` (when present)
69    /// is parsed and its path/workspace deps follow the same pipeline. Cycles
70    /// are detected via the `visiting` set and reported as an error.
71    ///
72    /// When the root manifest has `[workspace]`, every member's deps are
73    /// resolved into the same graph, and `dep.workspace = true` resolves
74    /// against the root's `[workspace.deps]` table. The lockfile is single
75    /// and lives at the workspace root.
76    pub fn resolve(&self) -> PkgResult<ResolveOutcome> {
77        self.store.ensure_layout()?;
78
79        let mut graph: BTreeMap<String, ResolvedDep> = BTreeMap::new();
80        let mut installed: Vec<(String, String, PathBuf)> = Vec::new();
81        let mut visiting: BTreeSet<String> = BTreeSet::new();
82
83        // Direct deps first — registry/git deps fail loud here. Workspace
84        // member manifests are resolved underneath this same loop so the
85        // single lockfile sees the union of all members' dep graphs.
86        let direct = self.collect_direct_deps();
87        for (name, spec) in &direct {
88            let resolved_spec = self.resolve_workspace_dep(name, spec)?;
89            self.walk_dep(
90                name,
91                &resolved_spec,
92                self.manifest_dir,
93                &mut graph,
94                &mut installed,
95                &mut visiting,
96            )?;
97        }
98
99        // Workspace members each contribute their direct deps to the same graph.
100        if let Some(ws) = &self.manifest.workspace {
101            for member_pattern in &ws.members {
102                let member_dirs = expand_workspace_glob(self.manifest_dir, member_pattern)?;
103                for member_dir in member_dirs {
104                    let member_manifest_path = member_dir.join("stryke.toml");
105                    if !member_manifest_path.is_file() {
106                        return Err(PkgError::Resolve(format!(
107                            "workspace member {} has no stryke.toml",
108                            member_dir.display()
109                        )));
110                    }
111                    let member_manifest = Manifest::from_path(&member_manifest_path)?;
112                    for (name, spec) in member_manifest
113                        .deps
114                        .iter()
115                        .chain(member_manifest.dev_deps.iter())
116                        .chain(member_manifest.groups.values().flat_map(|g| g.iter()))
117                    {
118                        let resolved_spec = self.resolve_workspace_dep(name, spec)?;
119                        self.walk_dep(
120                            name,
121                            &resolved_spec,
122                            &member_dir,
123                            &mut graph,
124                            &mut installed,
125                            &mut visiting,
126                        )?;
127                    }
128                }
129            }
130        }
131
132        let mut lockfile = Lockfile::new();
133        for (_, dep) in graph {
134            lockfile.packages.push(LockedPackage {
135                name: dep.name,
136                version: dep.version,
137                source: dep.source,
138                integrity: dep.integrity,
139                features: dep.features,
140                deps: dep.deps,
141            });
142        }
143        lockfile.canonicalize();
144        Ok(ResolveOutcome {
145            lockfile,
146            installed,
147        })
148    }
149
150    /// If `spec` is `{ workspace = true }`, look up the name in the root
151    /// manifest's `[workspace.deps]` and return that. Otherwise return the
152    /// spec unchanged. This is the inheritance mechanism that lets every
153    /// workspace member share one version of `http`/`json`/etc.
154    ///
155    /// Path deps inherited from `[workspace.deps]` are absolutized against
156    /// the workspace root so the subsequent walk doesn't re-resolve them
157    /// relative to the member directory (which would point at a wrong path).
158    fn resolve_workspace_dep(&self, name: &str, spec: &DepSpec) -> PkgResult<DepSpec> {
159        let inherits = matches!(spec, DepSpec::Detailed(d) if d.workspace);
160        if !inherits {
161            return Ok(spec.clone());
162        }
163        let ws = match &self.manifest.workspace {
164            Some(w) => w,
165            None => {
166                return Err(PkgError::Resolve(format!(
167                    "dep `{}` has `workspace = true` but the root manifest has no [workspace] table",
168                    name
169                )));
170            }
171        };
172        let inherited = ws.deps.get(name).ok_or_else(|| {
173            PkgError::Resolve(format!(
174                "dep `{}` inherits from [workspace.deps] but no such entry exists in the root manifest",
175                name
176            ))
177        })?;
178        let mut absolutized = match inherited.clone() {
179            DepSpec::Detailed(mut d) => {
180                if let Some(p) = d.path.as_ref() {
181                    let pp = std::path::Path::new(p);
182                    if !pp.is_absolute() {
183                        let abs = self.manifest_dir.join(pp);
184                        d.path = Some(abs.to_string_lossy().into_owned());
185                    }
186                }
187                DepSpec::Detailed(d)
188            }
189            other => other,
190        };
191        // Merge: dep-side `features` accumulate on top of the inherited spec.
192        if let DepSpec::Detailed(member) = spec {
193            if !member.features.is_empty() {
194                let mut merged = match absolutized {
195                    DepSpec::Detailed(d) => d,
196                    DepSpec::Version(v) => DetailedDep {
197                        version: Some(v),
198                        default_features: true,
199                        ..DetailedDep::default()
200                    },
201                    DepSpec::Placeholder => DetailedDep::default(),
202                };
203                for f in &member.features {
204                    if !merged.features.contains(f) {
205                        merged.features.push(f.clone());
206                    }
207                }
208                absolutized = DepSpec::Detailed(merged);
209            }
210        }
211        Ok(absolutized)
212    }
213
214    /// Flatten direct deps from `[deps]`, `[dev-deps]`, and every `[groups.*]`.
215    fn collect_direct_deps(&self) -> Vec<(String, DepSpec)> {
216        let mut out: Vec<(String, DepSpec)> = Vec::new();
217        for (k, v) in &self.manifest.deps {
218            out.push((k.clone(), v.clone()));
219        }
220        for (k, v) in &self.manifest.dev_deps {
221            out.push((k.clone(), v.clone()));
222        }
223        for (_group_name, group_map) in &self.manifest.groups {
224            for (k, v) in group_map {
225                out.push((k.clone(), v.clone()));
226            }
227        }
228        out
229    }
230
231    /// Resolve one dep edge. For path deps, copies into the store, hashes, and
232    /// recurses into the path dep's own manifest. For registry/git deps,
233    /// returns a clear unimplemented error (Tier 2/3).
234    fn walk_dep(
235        &self,
236        name: &str,
237        spec: &DepSpec,
238        relative_to: &Path,
239        graph: &mut BTreeMap<String, ResolvedDep>,
240        installed: &mut Vec<(String, String, PathBuf)>,
241        visiting: &mut BTreeSet<String>,
242    ) -> PkgResult<()> {
243        match spec.source() {
244            DepSource::Path => {
245                let raw_path = spec.path().expect("path dep has path");
246                let path = resolve_path(relative_to, raw_path);
247                self.install_path_dep(name, &path, graph, installed, visiting)?;
248                Ok(())
249            }
250            DepSource::Git => self.install_git_dep(name, spec, graph, installed, visiting),
251            DepSource::GitHub => self.install_github_dep(name, spec, graph, installed, visiting),
252            DepSource::Registry => Err(PkgError::Resolve(format!(
253                "registry dep `{}` is not supported in this stryke version yet \
254                 (RFC phases 7–8 — see docs/PACKAGE_REGISTRY.md). Use `path = \"...\"` \
255                 to depend on a local copy in the meantime.",
256                name
257            ))),
258        }
259    }
260
261    /// Handle a `{ git = "..." }` dep. `s install` never source-clones —
262    /// it downloads the prebuilt release tarball. github URLs route
263    /// straight to `install_from_github_release`. Non-github git URLs
264    /// have no release-tarball convention defined yet and error fast
265    /// with a pointer at the `{ github = "OWNER/REPO" }` or `{ path = "..." }`
266    /// rewrites.
267    fn install_git_dep(
268        &self,
269        name: &str,
270        spec: &DepSpec,
271        graph: &mut BTreeMap<String, ResolvedDep>,
272        installed: &mut Vec<(String, String, PathBuf)>,
273        visiting: &mut BTreeSet<String>,
274    ) -> PkgResult<()> {
275        let url = spec
276            .git()
277            .ok_or_else(|| PkgError::Resolve(format!("git dep `{}` missing url", name)))?
278            .to_string();
279
280        let Some((owner, repo)) = parse_github_url(&url) else {
281            return Err(PkgError::Resolve(format!(
282                "git dep `{}` URL `{}` is not a github.com URL — `s install` \
283                 doesn't source-clone, it downloads prebuilt release tarballs. \
284                 Rewrite as `{{ github = \"OWNER/REPO\" }}` for github-hosted \
285                 packages or `{{ path = \"...\" }}` for local checkouts.",
286                name, url
287            )));
288        };
289
290        // Pull the release-tarball path. The tag pin (if any) comes from
291        // the spec's `tag` field. `branch` is ignored — releases live at
292        // tags, not branch tips. `rev` is also unused for release fetch.
293        let pinned = match spec {
294            DepSpec::Detailed(d) => d.tag.as_deref(),
295            _ => None,
296        };
297        self.install_from_github_release(name, &owner, &repo, pinned, graph, installed, visiting)
298    }
299
300    /// `{ github = "OWNER/REPO" [, version = "..."] }` — fetch the
301    /// prebuilt release tarball for the host triple. Delegates to the
302    /// shared `install_global_from_github` helper in `pkg::commands`.
303    fn install_github_dep(
304        &self,
305        name: &str,
306        spec: &DepSpec,
307        graph: &mut BTreeMap<String, ResolvedDep>,
308        installed: &mut Vec<(String, String, PathBuf)>,
309        visiting: &mut BTreeSet<String>,
310    ) -> PkgResult<()> {
311        let owner_repo = spec.github().ok_or_else(|| {
312            PkgError::Resolve(format!("github dep `{}` missing OWNER/REPO", name))
313        })?;
314        let (owner, repo) = owner_repo.split_once('/').ok_or_else(|| {
315            PkgError::Resolve(format!(
316                "github dep `{}`: expected `OWNER/REPO`, got `{}`",
317                name, owner_repo
318            ))
319        })?;
320        if owner.is_empty() || repo.is_empty() {
321            return Err(PkgError::Resolve(format!(
322                "github dep `{}`: empty owner or repo in `{}`",
323                name, owner_repo
324            )));
325        }
326        let pinned = spec.pinned_release_version().map(str::to_string);
327        self.install_from_github_release(
328            name,
329            owner,
330            repo,
331            pinned.as_deref(),
332            graph,
333            installed,
334            visiting,
335        )
336    }
337
338    /// Shared release-tarball installer used by both `install_github_dep`
339    /// (explicit `github = "X/Y"` form) and the github-URL branch of
340    /// `install_git_dep` (auto-route from `git = "https://github.com/X/Y"`).
341    /// Calls `install_global_from_github` to download + verify + extract,
342    /// then threads the result through the standard graph/installed/lockfile
343    /// bookkeeping.
344    fn install_from_github_release(
345        &self,
346        name: &str,
347        owner: &str,
348        repo: &str,
349        pinned: Option<&str>,
350        graph: &mut BTreeMap<String, ResolvedDep>,
351        installed: &mut Vec<(String, String, PathBuf)>,
352        visiting: &mut BTreeSet<String>,
353    ) -> PkgResult<()> {
354        let (manifest, dst, source) =
355            super::commands::install_global_from_github(&self.store, owner, repo, pinned)
356                .map_err(PkgError::Resolve)?;
357
358        let pkg = manifest
359            .package
360            .as_ref()
361            .ok_or_else(|| PkgError::Resolve(format!("github dep `{}`: tarball missing [package]", name)))?;
362        let version = pkg.version.clone();
363        let integrity = integrity_for_directory(&dst)?;
364        let key = format!("{}@{}", name, version);
365        if graph.contains_key(&key) {
366            return Ok(());
367        }
368        installed.push((name.to_string(), version.clone(), dst.clone()));
369
370        // Recurse into transitive deps — a github-released package may
371        // declare its own [deps] table in the bundled stryke.toml.
372        let mut transitive: Vec<String> = Vec::new();
373        for (sub_name, sub_spec) in &manifest.deps {
374            self.walk_dep(sub_name, sub_spec, &dst, graph, installed, visiting)?;
375            let sub_version = graph
376                .values()
377                .find(|d| &d.name == sub_name)
378                .map(|d| d.version.clone())
379                .unwrap_or_else(|| "0.0.0".to_string());
380            transitive.push(format!("{}@{}", sub_name, sub_version));
381        }
382        transitive.sort();
383        transitive.dedup();
384
385        graph.insert(
386            key,
387            ResolvedDep {
388                name: name.to_string(),
389                version,
390                source,
391                integrity,
392                deps: transitive,
393                features: Vec::new(),
394            },
395        );
396        Ok(())
397    }
398
399    /// Pull a path-dep manifest, hash the directory, copy into the store, and
400    /// recurse into its own deps. The path dep's `stryke.toml` is optional —
401    /// raw `.stk` source trees with no manifest are treated as version `"0.0.0"`.
402    fn install_path_dep(
403        &self,
404        name: &str,
405        src: &Path,
406        graph: &mut BTreeMap<String, ResolvedDep>,
407        installed: &mut Vec<(String, String, PathBuf)>,
408        visiting: &mut BTreeSet<String>,
409    ) -> PkgResult<()> {
410        let canonical_src = src.canonicalize().unwrap_or_else(|_| src.to_path_buf());
411        let source = format!("path+file://{}", canonical_src.display());
412        self.install_dir_dep(name, src, source, graph, installed, visiting)
413    }
414
415    /// Shared installer for any source-tree dep (path or git). Reads the
416    /// nested manifest, hashes the tree, copies to the store, recurses into
417    /// transitive deps, and records the lockfile entry with the caller-
418    /// supplied `source` string (`path+file://...` or `git+<url>#<rev>`).
419    fn install_dir_dep(
420        &self,
421        name: &str,
422        src: &Path,
423        source: String,
424        graph: &mut BTreeMap<String, ResolvedDep>,
425        installed: &mut Vec<(String, String, PathBuf)>,
426        visiting: &mut BTreeSet<String>,
427    ) -> PkgResult<()> {
428        if !src.is_dir() {
429            return Err(PkgError::Resolve(format!(
430                "dep `{}` does not exist or is not a directory: {}",
431                name,
432                src.display()
433            )));
434        }
435
436        let nested_manifest_path = src.join("stryke.toml");
437        let nested = if nested_manifest_path.is_file() {
438            Some(Manifest::from_path(&nested_manifest_path)?)
439        } else {
440            None
441        };
442
443        let version = nested
444            .as_ref()
445            .and_then(|m| m.package.as_ref())
446            .map(|p| p.version.clone())
447            .unwrap_or_else(|| "0.0.0".to_string());
448
449        let key = format!("{}@{}", name, version);
450        if graph.contains_key(&key) {
451            return Ok(());
452        }
453        if !visiting.insert(key.clone()) {
454            return Err(PkgError::Resolve(format!(
455                "cyclic dependency detected at `{}`",
456                key
457            )));
458        }
459
460        let integrity = integrity_for_directory(src)?;
461        let manifest_for_copy = nested.clone().unwrap_or_default();
462
463        // For [ffi] path deps, build the cdylib in the source tree (or
464        // pick up an existing target/release/ artifact) BEFORE copying
465        // the subtree into the store — that way `lib/lib<name>.<ext>`
466        // is in place either via the source layout (`lib/`) or via the
467        // post-copy stage step below. Without this, `use Foo` against
468        // a freshly-installed path dep would dlopen-fail at runtime
469        // because the store entry would have only `lib/*.stk` and no
470        // cdylib. Mirrors `s pkg install -g <path>`'s flow exactly.
471        let ffi_stage = super::commands::ensure_ffi_cdylib(src, &manifest_for_copy)
472            .map_err(PkgError::Resolve)?;
473
474        let dst = self
475            .store
476            .install_path_dep(name, &version, src, &manifest_for_copy)?;
477
478        // Stage the freshly-built (or target/-located) cdylib into
479        // dst/lib/ when ensure_ffi_cdylib returned Some — the source's
480        // own lib/ didn't already have it.
481        if let Some((built_lib, lib_filename)) = ffi_stage {
482            let dst_lib_dir = dst.join("lib");
483            std::fs::create_dir_all(&dst_lib_dir).map_err(|e| {
484                PkgError::Io(format!("create {}: {}", dst_lib_dir.display(), e))
485            })?;
486            let dst_lib = dst_lib_dir.join(&lib_filename);
487            if !dst_lib.is_file() {
488                std::fs::copy(&built_lib, &dst_lib).map_err(|e| {
489                    PkgError::Io(format!(
490                        "stage cdylib {} -> {}: {}",
491                        built_lib.display(),
492                        dst_lib.display(),
493                        e
494                    ))
495                })?;
496                eprintln!("  staged {} -> {}", built_lib.display(), dst_lib.display());
497            }
498        }
499
500        installed.push((name.to_string(), version.clone(), dst.clone()));
501
502        let mut transitive: Vec<String> = Vec::new();
503        if let Some(nm) = nested.as_ref() {
504            for (sub_name, sub_spec) in &nm.deps {
505                self.walk_dep(sub_name, sub_spec, src, graph, installed, visiting)?;
506                let sub_version = graph
507                    .values()
508                    .find(|d| &d.name == sub_name)
509                    .map(|d| d.version.clone())
510                    .unwrap_or_else(|| "0.0.0".to_string());
511                transitive.push(format!("{}@{}", sub_name, sub_version));
512            }
513        }
514        transitive.sort();
515        transitive.dedup();
516
517        graph.insert(
518            key.clone(),
519            ResolvedDep {
520                name: name.to_string(),
521                version,
522                source,
523                integrity,
524                deps: transitive,
525                features: Vec::new(),
526            },
527        );
528        visiting.remove(&key);
529        Ok(())
530    }
531}
532
533/// Recognize `https://github.com/OWNER/REPO[.git]` (or the bare
534/// `github.com/OWNER/REPO` form, or with the `git@github.com:OWNER/REPO`
535/// SSH-style form). Returns `Some((owner, repo))` so the caller can
536/// route the dep to the release-tarball path instead of source-clone.
537/// Trailing slashes, embedded sub-paths (`/tree/main`), and missing
538/// owner/repo components fail the match — those are not bare repo URLs.
539pub(crate) fn parse_github_url(url: &str) -> Option<(String, String)> {
540    let body = if let Some(rest) = url.strip_prefix("https://github.com/") {
541        rest
542    } else if let Some(rest) = url.strip_prefix("http://github.com/") {
543        rest
544    } else if let Some(rest) = url.strip_prefix("git@github.com:") {
545        rest
546    } else if let Some(rest) = url.strip_prefix("github.com/") {
547        rest
548    } else {
549        return None;
550    };
551    let body = body.trim_end_matches('/');
552    let mut parts = body.splitn(2, '/');
553    let owner = parts.next()?;
554    let remainder = parts.next()?;
555    if owner.is_empty() || remainder.contains('/') {
556        return None;
557    }
558    let repo = remainder.strip_suffix(".git").unwrap_or(remainder);
559    if repo.is_empty() {
560        return None;
561    }
562    Some((owner.to_string(), repo.to_string()))
563}
564
565/// Resolve a (possibly relative) dep path against the dep's containing manifest dir.
566fn resolve_path(relative_to: &Path, raw: &str) -> PathBuf {
567    let p = Path::new(raw);
568    if p.is_absolute() {
569        p.to_path_buf()
570    } else {
571        relative_to.join(p)
572    }
573}
574
575/// Expand a workspace `members = ["..."]` pattern against `root_dir`. Supports
576/// the two cases the RFC calls out: literal dirs (`crates/foo`) and one-level
577/// wildcards (`crates/*`). Multi-segment globs and `**` are not supported —
578/// the workspace pattern is a directory list, not a generic glob.
579fn expand_workspace_glob(root_dir: &Path, pattern: &str) -> PkgResult<Vec<PathBuf>> {
580    if let Some(prefix) = pattern.strip_suffix("/*") {
581        let parent = root_dir.join(prefix);
582        if !parent.is_dir() {
583            return Ok(Vec::new());
584        }
585        let mut out: Vec<PathBuf> = Vec::new();
586        for entry in std::fs::read_dir(&parent)
587            .map_err(|e| PkgError::Io(format!("read workspace dir {}: {}", parent.display(), e)))?
588        {
589            let entry = entry.map_err(|e| PkgError::Io(e.to_string()))?;
590            if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
591                out.push(entry.path());
592            }
593        }
594        out.sort();
595        Ok(out)
596    } else if pattern.contains('*') {
597        Err(PkgError::Resolve(format!(
598            "workspace member pattern `{}` not supported — only literal dirs and `prefix/*` work today",
599            pattern
600        )))
601    } else {
602        Ok(vec![root_dir.join(pattern)])
603    }
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609    use crate::pkg::manifest::DepSpec;
610    use indexmap::IndexMap;
611
612    fn tempdir(tag: &str) -> PathBuf {
613        let pid = std::process::id();
614        let nanos = std::time::SystemTime::now()
615            .duration_since(std::time::UNIX_EPOCH)
616            .unwrap()
617            .subsec_nanos();
618        let p = std::env::temp_dir().join(format!("stryke-resolver-{}-{}-{}", tag, pid, nanos));
619        let _ = std::fs::remove_dir_all(&p);
620        std::fs::create_dir_all(&p).unwrap();
621        p
622    }
623
624    #[test]
625    fn parse_github_url_https_form() {
626        let (o, r) = parse_github_url("https://github.com/MenkeTechnologies/stryke-arrow").unwrap();
627        assert_eq!(o, "MenkeTechnologies");
628        assert_eq!(r, "stryke-arrow");
629    }
630
631    #[test]
632    fn parse_github_url_strips_dot_git() {
633        let (_, r) = parse_github_url("https://github.com/foo/bar.git").unwrap();
634        assert_eq!(r, "bar");
635    }
636
637    #[test]
638    fn parse_github_url_ssh_form() {
639        let (o, r) = parse_github_url("git@github.com:foo/bar.git").unwrap();
640        assert_eq!(o, "foo");
641        assert_eq!(r, "bar");
642    }
643
644    #[test]
645    fn parse_github_url_bare_host_form() {
646        let (o, r) = parse_github_url("github.com/MenkeTechnologies/stryke-aws").unwrap();
647        assert_eq!(o, "MenkeTechnologies");
648        assert_eq!(r, "stryke-aws");
649    }
650
651    #[test]
652    fn parse_github_url_rejects_non_github() {
653        // Non-github URLs error out at install time — we never source-clone.
654        assert!(parse_github_url("https://gitlab.com/foo/bar").is_none());
655        assert!(parse_github_url("git://example.com/foo.git").is_none());
656        assert!(parse_github_url("file:///tmp/local.git").is_none());
657    }
658
659    #[test]
660    fn parse_github_url_rejects_subpath() {
661        // `tree/main`, `releases`, etc. must not silently truncate to the repo.
662        assert!(parse_github_url("https://github.com/foo/bar/tree/main").is_none());
663        assert!(parse_github_url("https://github.com/foo/bar/releases").is_none());
664    }
665
666    #[test]
667    fn parse_github_url_rejects_empty() {
668        assert!(parse_github_url("https://github.com/").is_none());
669        assert!(parse_github_url("https://github.com/foo").is_none());
670        assert!(parse_github_url("https://github.com/foo/").is_none());
671        assert!(parse_github_url("https://github.com/foo/.git").is_none());
672    }
673
674    fn make_path_dep(name: &str, version: &str) -> PathBuf {
675        let dir = tempdir(name);
676        std::fs::create_dir_all(dir.join("lib")).unwrap();
677        std::fs::write(
678            dir.join("stryke.toml"),
679            format!(
680                "[package]\nname = \"{}\"\nversion = \"{}\"\n",
681                name, version
682            ),
683        )
684        .unwrap();
685        std::fs::write(
686            dir.join("lib").join(format!("{}.stk", name)),
687            format!("# {}", name),
688        )
689        .unwrap();
690        dir
691    }
692
693    #[test]
694    fn resolves_single_path_dep() {
695        let dep = make_path_dep("mylib", "1.0.0");
696        let project = tempdir("project");
697        let mut deps = IndexMap::new();
698        deps.insert(
699            "mylib".to_string(),
700            DepSpec::path_dep(dep.to_string_lossy().to_string()),
701        );
702        let m = Manifest {
703            package: Some(crate::pkg::manifest::PackageMeta {
704                name: "myapp".into(),
705                version: "0.1.0".into(),
706                ..Default::default()
707            }),
708            deps,
709            ..Manifest::default()
710        };
711
712        let store_root = tempdir("store");
713        let store = Store::at(&store_root);
714        let r = Resolver {
715            manifest: &m,
716            manifest_dir: &project,
717            store: &store,
718        };
719        let outcome = r.resolve().unwrap();
720        assert_eq!(outcome.lockfile.packages.len(), 1);
721        assert_eq!(outcome.lockfile.packages[0].name, "mylib");
722        assert_eq!(outcome.lockfile.packages[0].version, "1.0.0");
723        assert!(outcome.lockfile.packages[0]
724            .integrity
725            .starts_with("sha256-"));
726        assert!(store.package_dir("mylib", "1.0.0").is_dir());
727    }
728
729    #[test]
730    fn registry_dep_returns_unimplemented_error() {
731        let project = tempdir("project");
732        let mut m = Manifest {
733            package: Some(crate::pkg::manifest::PackageMeta {
734                name: "myapp".into(),
735                version: "0.1.0".into(),
736                ..Default::default()
737            }),
738            ..Manifest::default()
739        };
740        m.deps.insert("http".to_string(), DepSpec::version("1.0"));
741
742        let store_root = tempdir("store");
743        let store = Store::at(&store_root);
744        let r = Resolver {
745            manifest: &m,
746            manifest_dir: &project,
747            store: &store,
748        };
749        let err = r.resolve().unwrap_err();
750        let msg = err.to_string();
751        assert!(msg.contains("registry dep"), "got: {}", msg);
752        assert!(msg.contains("http"), "got: {}", msg);
753    }
754
755    #[test]
756    fn workspace_resolves_member_deps_into_root_lockfile() {
757        // Build a workspace with two members that share a common path-dep via
758        // `[workspace.deps]` + `workspace = true`. Single root lockfile sees both.
759        let leaf = make_path_dep("shared", "1.0.0");
760
761        let ws_root = tempdir("ws_root");
762        std::fs::create_dir_all(ws_root.join("crates/a/lib")).unwrap();
763        std::fs::create_dir_all(ws_root.join("crates/b/lib")).unwrap();
764        std::fs::write(
765            ws_root.join("stryke.toml"),
766            format!(
767                r#"
768[workspace]
769members = ["crates/*"]
770
771[workspace.deps]
772shared = {{ path = "{}" }}
773"#,
774                leaf.display()
775            ),
776        )
777        .unwrap();
778        std::fs::write(
779            ws_root.join("crates/a/stryke.toml"),
780            "[package]\nname = \"a\"\nversion = \"0.1.0\"\n\n[deps]\nshared = { workspace = true }\n",
781        )
782        .unwrap();
783        std::fs::write(
784            ws_root.join("crates/b/stryke.toml"),
785            "[package]\nname = \"b\"\nversion = \"0.1.0\"\n\n[deps]\nshared = { workspace = true }\n",
786        )
787        .unwrap();
788
789        let ws_manifest = Manifest::from_path(&ws_root.join("stryke.toml")).unwrap();
790        let store_root = tempdir("ws_store");
791        let store = Store::at(&store_root);
792        let r = Resolver {
793            manifest: &ws_manifest,
794            manifest_dir: &ws_root,
795            store: &store,
796        };
797        let outcome = r.resolve().unwrap();
798        // Single dep in the lockfile — both members share the same `shared@1.0.0`.
799        assert_eq!(outcome.lockfile.packages.len(), 1);
800        assert_eq!(outcome.lockfile.packages[0].name, "shared");
801        assert_eq!(outcome.lockfile.packages[0].version, "1.0.0");
802    }
803
804    #[test]
805    fn workspace_glob_returns_sorted_member_dirs() {
806        let root = tempdir("ws_glob");
807        for n in ["zeta", "alpha", "beta"] {
808            std::fs::create_dir_all(root.join(format!("crates/{}", n))).unwrap();
809        }
810        let dirs = super::expand_workspace_glob(&root, "crates/*").unwrap();
811        let names: Vec<String> = dirs
812            .iter()
813            .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
814            .collect();
815        assert_eq!(names, vec!["alpha", "beta", "zeta"]);
816    }
817
818    #[test]
819    fn workspace_dep_without_table_is_an_error() {
820        let root = tempdir("ws_err");
821        std::fs::write(
822            root.join("stryke.toml"),
823            "[package]\nname = \"x\"\nversion = \"0.1.0\"\n\n[deps]\nshared = { workspace = true }\n",
824        )
825        .unwrap();
826        let m = Manifest::from_path(&root.join("stryke.toml")).unwrap();
827        let store_root = tempdir("ws_err_store");
828        let store = Store::at(&store_root);
829        let r = Resolver {
830            manifest: &m,
831            manifest_dir: &root,
832            store: &store,
833        };
834        let err = r.resolve().unwrap_err().to_string();
835        assert!(err.contains("workspace = true"), "got: {}", err);
836    }
837
838    #[test]
839    fn transitive_path_dep_recursion() {
840        let leaf = make_path_dep("leaf", "0.1.0");
841        let mid = make_path_dep("mid", "0.2.0");
842        // Mid depends on leaf via path = "<leaf-abs-path>"
843        let mid_manifest = format!(
844            "[package]\nname = \"mid\"\nversion = \"0.2.0\"\n\n[deps]\nleaf = {{ path = \"{}\" }}\n",
845            leaf.display()
846        );
847        std::fs::write(mid.join("stryke.toml"), mid_manifest).unwrap();
848
849        let project = tempdir("project");
850        let mut m = Manifest {
851            package: Some(crate::pkg::manifest::PackageMeta {
852                name: "myapp".into(),
853                version: "0.1.0".into(),
854                ..Default::default()
855            }),
856            ..Manifest::default()
857        };
858        m.deps.insert(
859            "mid".to_string(),
860            DepSpec::path_dep(mid.to_string_lossy().to_string()),
861        );
862
863        let store_root = tempdir("store");
864        let store = Store::at(&store_root);
865        let r = Resolver {
866            manifest: &m,
867            manifest_dir: &project,
868            store: &store,
869        };
870        let outcome = r.resolve().unwrap();
871        assert_eq!(outcome.lockfile.packages.len(), 2);
872        let names: Vec<&str> = outcome
873            .lockfile
874            .packages
875            .iter()
876            .map(|p| p.name.as_str())
877            .collect();
878        assert!(names.contains(&"leaf"));
879        assert!(names.contains(&"mid"));
880        let mid_entry = outcome.lockfile.find("mid").unwrap();
881        assert_eq!(mid_entry.deps, vec!["leaf@0.1.0"]);
882    }
883}