1use 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
20pub struct Resolver<'a> {
22 pub manifest: &'a Manifest,
24 pub manifest_dir: &'a Path,
26 pub store: &'a Store,
28}
29
30#[derive(Debug, Clone)]
32struct ResolvedDep {
33 name: String,
34 version: String,
35 source: String,
36 integrity: String,
37 deps: Vec<String>,
39 features: Vec<String>,
41}
42
43#[derive(Debug, Clone)]
45pub struct ResolveOutcome {
46 pub lockfile: Lockfile,
48 pub installed: Vec<(String, String, PathBuf)>,
51}
52
53impl<'a> Resolver<'a> {
54 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 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 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 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 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 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 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 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
333fn 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
343fn 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 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 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 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}