Skip to main content

stryke/pkg/
resolver.rs

1//! Resolution pipeline. Local-only for Tier 1 (RFC phases 1–6):
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//! - Registry (`http = "1.0"`) and git (`{ git = "..." }`) deps return a
6//!   structured "not yet implemented" error so the CLI surface is honest
7//!   about what's wired today.
8//!
9//! When the registry / PubGrub semver / parallel fetch land (RFC phases 7-9),
10//! they slot in at [`Resolver::resolve`] without changing the lockfile shape.
11
12use std::collections::{BTreeMap, BTreeSet};
13use std::path::{Path, PathBuf};
14
15use super::lockfile::{integrity_for_directory, LockedPackage, Lockfile};
16use super::manifest::{DepSource, DepSpec, DetailedDep, Manifest};
17use super::store::Store;
18use super::{PkgError, PkgResult};
19
20/// Drives manifest → lockfile resolution against a [`Store`].
21pub struct Resolver<'a> {
22    /// The project's `stryke.toml`.
23    pub manifest: &'a Manifest,
24    /// Directory containing `stryke.toml` — path deps are resolved relative to this.
25    pub manifest_dir: &'a Path,
26    /// Backing global store (`~/.stryke/...`).
27    pub store: &'a Store,
28}
29
30/// One concrete dep edge after resolution. Drives lockfile entry generation.
31#[derive(Debug, Clone)]
32struct ResolvedDep {
33    name: String,
34    version: String,
35    source: String,
36    integrity: String,
37    /// `name@version` strings of transitive deps, sorted.
38    deps: Vec<String>,
39    /// Features enabled for this resolution.
40    features: Vec<String>,
41}
42
43/// Outcome of resolving one project's dep graph.
44#[derive(Debug, Clone)]
45pub struct ResolveOutcome {
46    /// Lockfile snapshot ready to write to disk.
47    pub lockfile: Lockfile,
48    /// `(name, version, store_path)` for every dep that was newly extracted
49    /// or already present in the store. Useful for `s install` reporting.
50    pub installed: Vec<(String, String, PathBuf)>,
51}
52
53impl<'a> Resolver<'a> {
54    /// Resolve the manifest's runtime + dev + group deps into a lockfile and
55    /// install all path/workspace deps into the store.
56    ///
57    /// Walks deps recursively: each path dep's own `stryke.toml` (when present)
58    /// is parsed and its path/workspace deps follow the same pipeline. Cycles
59    /// are detected via the `visiting` set and reported as an error.
60    ///
61    /// When the root manifest has `[workspace]`, every member's deps are
62    /// resolved into the same graph, and `dep.workspace = true` resolves
63    /// against the root's `[workspace.deps]` table. The lockfile is single
64    /// and lives at the workspace root.
65    pub fn resolve(&self) -> PkgResult<ResolveOutcome> {
66        self.store.ensure_layout()?;
67
68        let mut graph: BTreeMap<String, ResolvedDep> = BTreeMap::new();
69        let mut installed: Vec<(String, String, PathBuf)> = Vec::new();
70        let mut visiting: BTreeSet<String> = BTreeSet::new();
71
72        // Direct deps first — registry/git deps fail loud here. Workspace
73        // member manifests are resolved underneath this same loop so the
74        // single lockfile sees the union of all members' dep graphs.
75        let direct = self.collect_direct_deps();
76        for (name, spec) in &direct {
77            let resolved_spec = self.resolve_workspace_dep(name, spec)?;
78            self.walk_dep(
79                name,
80                &resolved_spec,
81                self.manifest_dir,
82                &mut graph,
83                &mut installed,
84                &mut visiting,
85            )?;
86        }
87
88        // Workspace members each contribute their direct deps to the same graph.
89        if let Some(ws) = &self.manifest.workspace {
90            for member_pattern in &ws.members {
91                let member_dirs = expand_workspace_glob(self.manifest_dir, member_pattern)?;
92                for member_dir in member_dirs {
93                    let member_manifest_path = member_dir.join("stryke.toml");
94                    if !member_manifest_path.is_file() {
95                        return Err(PkgError::Resolve(format!(
96                            "workspace member {} has no stryke.toml",
97                            member_dir.display()
98                        )));
99                    }
100                    let member_manifest = Manifest::from_path(&member_manifest_path)?;
101                    for (name, spec) in member_manifest
102                        .deps
103                        .iter()
104                        .chain(member_manifest.dev_deps.iter())
105                        .chain(member_manifest.groups.values().flat_map(|g| g.iter()))
106                    {
107                        let resolved_spec = self.resolve_workspace_dep(name, spec)?;
108                        self.walk_dep(
109                            name,
110                            &resolved_spec,
111                            &member_dir,
112                            &mut graph,
113                            &mut installed,
114                            &mut visiting,
115                        )?;
116                    }
117                }
118            }
119        }
120
121        let mut lockfile = Lockfile::new();
122        for (_, dep) in graph {
123            lockfile.packages.push(LockedPackage {
124                name: dep.name,
125                version: dep.version,
126                source: dep.source,
127                integrity: dep.integrity,
128                features: dep.features,
129                deps: dep.deps,
130            });
131        }
132        lockfile.canonicalize();
133        Ok(ResolveOutcome {
134            lockfile,
135            installed,
136        })
137    }
138
139    /// If `spec` is `{ workspace = true }`, look up the name in the root
140    /// manifest's `[workspace.deps]` and return that. Otherwise return the
141    /// spec unchanged. This is the inheritance mechanism that lets every
142    /// workspace member share one version of `http`/`json`/etc.
143    ///
144    /// Path deps inherited from `[workspace.deps]` are absolutized against
145    /// the workspace root so the subsequent walk doesn't re-resolve them
146    /// relative to the member directory (which would point at a wrong path).
147    fn resolve_workspace_dep(&self, name: &str, spec: &DepSpec) -> PkgResult<DepSpec> {
148        let inherits = matches!(spec, DepSpec::Detailed(d) if d.workspace);
149        if !inherits {
150            return Ok(spec.clone());
151        }
152        let ws = match &self.manifest.workspace {
153            Some(w) => w,
154            None => {
155                return Err(PkgError::Resolve(format!(
156                    "dep `{}` has `workspace = true` but the root manifest has no [workspace] table",
157                    name
158                )));
159            }
160        };
161        let inherited = ws.deps.get(name).ok_or_else(|| {
162            PkgError::Resolve(format!(
163                "dep `{}` inherits from [workspace.deps] but no such entry exists in the root manifest",
164                name
165            ))
166        })?;
167        let mut absolutized = match inherited.clone() {
168            DepSpec::Detailed(mut d) => {
169                if let Some(p) = d.path.as_ref() {
170                    let pp = std::path::Path::new(p);
171                    if !pp.is_absolute() {
172                        let abs = self.manifest_dir.join(pp);
173                        d.path = Some(abs.to_string_lossy().into_owned());
174                    }
175                }
176                DepSpec::Detailed(d)
177            }
178            other => other,
179        };
180        // Merge: dep-side `features` accumulate on top of the inherited spec.
181        if let DepSpec::Detailed(member) = spec {
182            if !member.features.is_empty() {
183                let mut merged = match absolutized {
184                    DepSpec::Detailed(d) => d,
185                    DepSpec::Version(v) => DetailedDep {
186                        version: Some(v),
187                        default_features: true,
188                        ..DetailedDep::default()
189                    },
190                    DepSpec::Placeholder => DetailedDep::default(),
191                };
192                for f in &member.features {
193                    if !merged.features.contains(f) {
194                        merged.features.push(f.clone());
195                    }
196                }
197                absolutized = DepSpec::Detailed(merged);
198            }
199        }
200        Ok(absolutized)
201    }
202
203    /// Flatten direct deps from `[deps]`, `[dev-deps]`, and every `[groups.*]`.
204    fn collect_direct_deps(&self) -> Vec<(String, DepSpec)> {
205        let mut out: Vec<(String, DepSpec)> = Vec::new();
206        for (k, v) in &self.manifest.deps {
207            out.push((k.clone(), v.clone()));
208        }
209        for (k, v) in &self.manifest.dev_deps {
210            out.push((k.clone(), v.clone()));
211        }
212        for (_group_name, group_map) in &self.manifest.groups {
213            for (k, v) in group_map {
214                out.push((k.clone(), v.clone()));
215            }
216        }
217        out
218    }
219
220    /// Resolve one dep edge. For path deps, copies into the store, hashes, and
221    /// recurses into the path dep's own manifest. For registry/git deps,
222    /// returns a clear unimplemented error (Tier 2/3).
223    fn walk_dep(
224        &self,
225        name: &str,
226        spec: &DepSpec,
227        relative_to: &Path,
228        graph: &mut BTreeMap<String, ResolvedDep>,
229        installed: &mut Vec<(String, String, PathBuf)>,
230        visiting: &mut BTreeSet<String>,
231    ) -> PkgResult<()> {
232        match spec.source() {
233            DepSource::Path => {
234                let raw_path = spec.path().expect("path dep has path");
235                let path = resolve_path(relative_to, raw_path);
236                self.install_path_dep(name, &path, graph, installed, visiting)?;
237                Ok(())
238            }
239            DepSource::Git => Err(PkgError::Resolve(format!(
240                "git dep `{}` is not supported in this stryke version yet \
241                 (RFC phase 9 — see docs/PACKAGE_REGISTRY.md). Use `path = \"...\"` \
242                 to depend on a local checkout in the meantime.",
243                name
244            ))),
245            DepSource::Registry => Err(PkgError::Resolve(format!(
246                "registry dep `{}` is not supported in this stryke version yet \
247                 (RFC phases 7–8 — see docs/PACKAGE_REGISTRY.md). Use `path = \"...\"` \
248                 to depend on a local copy in the meantime.",
249                name
250            ))),
251        }
252    }
253
254    /// Pull a path-dep manifest, hash the directory, copy into the store, and
255    /// recurse into its own deps. The path dep's `stryke.toml` is optional —
256    /// raw `.stk` source trees with no manifest are treated as version `"0.0.0"`.
257    fn install_path_dep(
258        &self,
259        name: &str,
260        src: &Path,
261        graph: &mut BTreeMap<String, ResolvedDep>,
262        installed: &mut Vec<(String, String, PathBuf)>,
263        visiting: &mut BTreeSet<String>,
264    ) -> PkgResult<()> {
265        if !src.is_dir() {
266            return Err(PkgError::Resolve(format!(
267                "path dep `{}` does not exist or is not a directory: {}",
268                name,
269                src.display()
270            )));
271        }
272
273        let nested_manifest_path = src.join("stryke.toml");
274        let nested = if nested_manifest_path.is_file() {
275            Some(Manifest::from_path(&nested_manifest_path)?)
276        } else {
277            None
278        };
279
280        let version = nested
281            .as_ref()
282            .and_then(|m| m.package.as_ref())
283            .map(|p| p.version.clone())
284            .unwrap_or_else(|| "0.0.0".to_string());
285
286        let key = format!("{}@{}", name, version);
287        if graph.contains_key(&key) {
288            return Ok(());
289        }
290        if !visiting.insert(key.clone()) {
291            return Err(PkgError::Resolve(format!(
292                "cyclic dependency detected at `{}`",
293                key
294            )));
295        }
296
297        let integrity = integrity_for_directory(src)?;
298        let dst = self.store.install_path_dep(name, &version, src)?;
299        installed.push((name.to_string(), version.clone(), dst.clone()));
300
301        let mut transitive: Vec<String> = Vec::new();
302        if let Some(nm) = nested.as_ref() {
303            for (sub_name, sub_spec) in &nm.deps {
304                self.walk_dep(sub_name, sub_spec, src, graph, installed, visiting)?;
305                let sub_version = graph
306                    .values()
307                    .find(|d| &d.name == sub_name)
308                    .map(|d| d.version.clone())
309                    .unwrap_or_else(|| "0.0.0".to_string());
310                transitive.push(format!("{}@{}", sub_name, sub_version));
311            }
312        }
313        transitive.sort();
314        transitive.dedup();
315
316        let canonical_src = src.canonicalize().unwrap_or_else(|_| src.to_path_buf());
317        graph.insert(
318            key.clone(),
319            ResolvedDep {
320                name: name.to_string(),
321                version,
322                source: format!("path+file://{}", canonical_src.display()),
323                integrity,
324                deps: transitive,
325                features: Vec::new(),
326            },
327        );
328        visiting.remove(&key);
329        Ok(())
330    }
331}
332
333/// Resolve a (possibly relative) dep path against the dep's containing manifest dir.
334fn resolve_path(relative_to: &Path, raw: &str) -> PathBuf {
335    let p = Path::new(raw);
336    if p.is_absolute() {
337        p.to_path_buf()
338    } else {
339        relative_to.join(p)
340    }
341}
342
343/// Expand a workspace `members = ["..."]` pattern against `root_dir`. Supports
344/// the two cases the RFC calls out: literal dirs (`crates/foo`) and one-level
345/// wildcards (`crates/*`). Multi-segment globs and `**` are not supported —
346/// the workspace pattern is a directory list, not a generic glob.
347fn expand_workspace_glob(root_dir: &Path, pattern: &str) -> PkgResult<Vec<PathBuf>> {
348    if let Some(prefix) = pattern.strip_suffix("/*") {
349        let parent = root_dir.join(prefix);
350        if !parent.is_dir() {
351            return Ok(Vec::new());
352        }
353        let mut out: Vec<PathBuf> = Vec::new();
354        for entry in std::fs::read_dir(&parent)
355            .map_err(|e| PkgError::Io(format!("read workspace dir {}: {}", parent.display(), e)))?
356        {
357            let entry = entry.map_err(|e| PkgError::Io(e.to_string()))?;
358            if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
359                out.push(entry.path());
360            }
361        }
362        out.sort();
363        Ok(out)
364    } else if pattern.contains('*') {
365        Err(PkgError::Resolve(format!(
366            "workspace member pattern `{}` not supported — only literal dirs and `prefix/*` work today",
367            pattern
368        )))
369    } else {
370        Ok(vec![root_dir.join(pattern)])
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use crate::pkg::manifest::DepSpec;
378    use indexmap::IndexMap;
379
380    fn tempdir(tag: &str) -> PathBuf {
381        let pid = std::process::id();
382        let nanos = std::time::SystemTime::now()
383            .duration_since(std::time::UNIX_EPOCH)
384            .unwrap()
385            .subsec_nanos();
386        let p = std::env::temp_dir().join(format!("stryke-resolver-{}-{}-{}", tag, pid, nanos));
387        let _ = std::fs::remove_dir_all(&p);
388        std::fs::create_dir_all(&p).unwrap();
389        p
390    }
391
392    fn make_path_dep(name: &str, version: &str) -> PathBuf {
393        let dir = tempdir(name);
394        std::fs::create_dir_all(dir.join("lib")).unwrap();
395        std::fs::write(
396            dir.join("stryke.toml"),
397            format!(
398                "[package]\nname = \"{}\"\nversion = \"{}\"\n",
399                name, version
400            ),
401        )
402        .unwrap();
403        std::fs::write(
404            dir.join("lib").join(format!("{}.stk", name)),
405            format!("# {}", name),
406        )
407        .unwrap();
408        dir
409    }
410
411    #[test]
412    fn resolves_single_path_dep() {
413        let dep = make_path_dep("mylib", "1.0.0");
414        let project = tempdir("project");
415        let mut deps = IndexMap::new();
416        deps.insert(
417            "mylib".to_string(),
418            DepSpec::path_dep(dep.to_string_lossy().to_string()),
419        );
420        let m = Manifest {
421            package: Some(crate::pkg::manifest::PackageMeta {
422                name: "myapp".into(),
423                version: "0.1.0".into(),
424                ..Default::default()
425            }),
426            deps,
427            ..Manifest::default()
428        };
429
430        let store_root = tempdir("store");
431        let store = Store::at(&store_root);
432        let r = Resolver {
433            manifest: &m,
434            manifest_dir: &project,
435            store: &store,
436        };
437        let outcome = r.resolve().unwrap();
438        assert_eq!(outcome.lockfile.packages.len(), 1);
439        assert_eq!(outcome.lockfile.packages[0].name, "mylib");
440        assert_eq!(outcome.lockfile.packages[0].version, "1.0.0");
441        assert!(outcome.lockfile.packages[0]
442            .integrity
443            .starts_with("sha256-"));
444        assert!(store.package_dir("mylib", "1.0.0").is_dir());
445    }
446
447    #[test]
448    fn registry_dep_returns_unimplemented_error() {
449        let project = tempdir("project");
450        let mut m = Manifest {
451            package: Some(crate::pkg::manifest::PackageMeta {
452                name: "myapp".into(),
453                version: "0.1.0".into(),
454                ..Default::default()
455            }),
456            ..Manifest::default()
457        };
458        m.deps.insert("http".to_string(), DepSpec::version("1.0"));
459
460        let store_root = tempdir("store");
461        let store = Store::at(&store_root);
462        let r = Resolver {
463            manifest: &m,
464            manifest_dir: &project,
465            store: &store,
466        };
467        let err = r.resolve().unwrap_err();
468        let msg = err.to_string();
469        assert!(msg.contains("registry dep"), "got: {}", msg);
470        assert!(msg.contains("http"), "got: {}", msg);
471    }
472
473    #[test]
474    fn workspace_resolves_member_deps_into_root_lockfile() {
475        // Build a workspace with two members that share a common path-dep via
476        // `[workspace.deps]` + `workspace = true`. Single root lockfile sees both.
477        let leaf = make_path_dep("shared", "1.0.0");
478
479        let ws_root = tempdir("ws_root");
480        std::fs::create_dir_all(ws_root.join("crates/a/lib")).unwrap();
481        std::fs::create_dir_all(ws_root.join("crates/b/lib")).unwrap();
482        std::fs::write(
483            ws_root.join("stryke.toml"),
484            format!(
485                r#"
486[workspace]
487members = ["crates/*"]
488
489[workspace.deps]
490shared = {{ path = "{}" }}
491"#,
492                leaf.display()
493            ),
494        )
495        .unwrap();
496        std::fs::write(
497            ws_root.join("crates/a/stryke.toml"),
498            "[package]\nname = \"a\"\nversion = \"0.1.0\"\n\n[deps]\nshared = { workspace = true }\n",
499        )
500        .unwrap();
501        std::fs::write(
502            ws_root.join("crates/b/stryke.toml"),
503            "[package]\nname = \"b\"\nversion = \"0.1.0\"\n\n[deps]\nshared = { workspace = true }\n",
504        )
505        .unwrap();
506
507        let ws_manifest = Manifest::from_path(&ws_root.join("stryke.toml")).unwrap();
508        let store_root = tempdir("ws_store");
509        let store = Store::at(&store_root);
510        let r = Resolver {
511            manifest: &ws_manifest,
512            manifest_dir: &ws_root,
513            store: &store,
514        };
515        let outcome = r.resolve().unwrap();
516        // Single dep in the lockfile — both members share the same `shared@1.0.0`.
517        assert_eq!(outcome.lockfile.packages.len(), 1);
518        assert_eq!(outcome.lockfile.packages[0].name, "shared");
519        assert_eq!(outcome.lockfile.packages[0].version, "1.0.0");
520    }
521
522    #[test]
523    fn workspace_glob_returns_sorted_member_dirs() {
524        let root = tempdir("ws_glob");
525        for n in ["zeta", "alpha", "beta"] {
526            std::fs::create_dir_all(root.join(format!("crates/{}", n))).unwrap();
527        }
528        let dirs = super::expand_workspace_glob(&root, "crates/*").unwrap();
529        let names: Vec<String> = dirs
530            .iter()
531            .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
532            .collect();
533        assert_eq!(names, vec!["alpha", "beta", "zeta"]);
534    }
535
536    #[test]
537    fn workspace_dep_without_table_is_an_error() {
538        let root = tempdir("ws_err");
539        std::fs::write(
540            root.join("stryke.toml"),
541            "[package]\nname = \"x\"\nversion = \"0.1.0\"\n\n[deps]\nshared = { workspace = true }\n",
542        )
543        .unwrap();
544        let m = Manifest::from_path(&root.join("stryke.toml")).unwrap();
545        let store_root = tempdir("ws_err_store");
546        let store = Store::at(&store_root);
547        let r = Resolver {
548            manifest: &m,
549            manifest_dir: &root,
550            store: &store,
551        };
552        let err = r.resolve().unwrap_err().to_string();
553        assert!(err.contains("workspace = true"), "got: {}", err);
554    }
555
556    #[test]
557    fn transitive_path_dep_recursion() {
558        let leaf = make_path_dep("leaf", "0.1.0");
559        let mid = make_path_dep("mid", "0.2.0");
560        // Mid depends on leaf via path = "<leaf-abs-path>"
561        let mid_manifest = format!(
562            "[package]\nname = \"mid\"\nversion = \"0.2.0\"\n\n[deps]\nleaf = {{ path = \"{}\" }}\n",
563            leaf.display()
564        );
565        std::fs::write(mid.join("stryke.toml"), mid_manifest).unwrap();
566
567        let project = tempdir("project");
568        let mut m = Manifest {
569            package: Some(crate::pkg::manifest::PackageMeta {
570                name: "myapp".into(),
571                version: "0.1.0".into(),
572                ..Default::default()
573            }),
574            ..Manifest::default()
575        };
576        m.deps.insert(
577            "mid".to_string(),
578            DepSpec::path_dep(mid.to_string_lossy().to_string()),
579        );
580
581        let store_root = tempdir("store");
582        let store = Store::at(&store_root);
583        let r = Resolver {
584            manifest: &m,
585            manifest_dir: &project,
586            store: &store,
587        };
588        let outcome = r.resolve().unwrap();
589        assert_eq!(outcome.lockfile.packages.len(), 2);
590        let names: Vec<&str> = outcome
591            .lockfile
592            .packages
593            .iter()
594            .map(|p| p.name.as_str())
595            .collect();
596        assert!(names.contains(&"leaf"));
597        assert!(names.contains(&"mid"));
598        let mid_entry = outcome.lockfile.find("mid").unwrap();
599        assert_eq!(mid_entry.deps, vec!["leaf@0.1.0"]);
600    }
601}