1use indexmap::IndexMap;
8use std::path::{Path, PathBuf};
9
10use super::lockfile::Lockfile;
11use super::manifest::{DepSpec, DetailedDep, Manifest, PackageMeta};
12use super::resolver::Resolver;
13use super::store::{InstalledIndex, Store};
14use super::{PkgError, PkgResult};
15
16pub const MANIFEST_FILE: &str = "stryke.toml";
18pub const LOCKFILE_FILE: &str = "stryke.lock";
20
21pub fn find_project_root(start: &Path) -> Option<PathBuf> {
25 let mut cur = start.to_path_buf();
26 loop {
27 if cur.join(MANIFEST_FILE).is_file() {
28 return Some(cur);
29 }
30 if !cur.pop() {
31 return None;
32 }
33 }
34}
35
36fn is_help_flag(arg: &str) -> bool {
38 arg == "-h" || arg == "--help"
39}
40
41fn print_new_help() {
42 println!("usage: stryke new NAME");
43 println!();
44 println!("Scaffold a new stryke project at ./NAME/. Same layout as `stryke init`,");
45 println!("but creates the directory for you.");
46 println!();
47 println!("The new project gets:");
48 println!(" NAME/stryke.toml manifest with [package] and [bin]");
49 println!(" NAME/main.stk entry point");
50 println!(" NAME/lib/ library modules (used by `use Foo::Bar`)");
51 println!(" NAME/t/ test files (run with `s test`)");
52 println!(" NAME/benches/ benchmark files (run with `s bench`)");
53 println!(" NAME/bin/ additional executables");
54 println!(" NAME/examples/ example programs");
55 println!(" NAME/.gitignore ignores target/");
56}
57
58fn print_init_help() {
59 println!("usage: stryke init [NAME]");
60 println!();
61 println!("Scaffold the current directory as a stryke project. NAME defaults to the");
62 println!("cwd's basename. Writes stryke.toml + main.stk + lib/, t/, benches/, bin/,");
63 println!("examples/, .gitignore. Existing files are left alone.");
64}
65
66pub fn cmd_new(name: &str) -> i32 {
68 if is_help_flag(name) {
69 print_new_help();
70 return 0;
71 }
72 let project_dir = PathBuf::from(name);
73 if project_dir.exists() {
74 eprintln!("s new: {} already exists", name);
75 return 1;
76 }
77 if let Err(e) = std::fs::create_dir_all(&project_dir) {
78 eprintln!("s new: create {}: {}", project_dir.display(), e);
79 return 1;
80 }
81 scaffold_project(&project_dir, name)
82}
83
84pub fn cmd_init(name: Option<&str>) -> i32 {
87 if matches!(name, Some(n) if is_help_flag(n)) {
88 print_init_help();
89 return 0;
90 }
91 let cwd = match std::env::current_dir() {
92 Ok(c) => c,
93 Err(e) => {
94 eprintln!("s init: cwd: {}", e);
95 return 1;
96 }
97 };
98 let resolved_name = name
99 .map(|s| s.to_string())
100 .or_else(|| cwd.file_name().map(|n| n.to_string_lossy().into_owned()))
101 .unwrap_or_else(|| "stryke_project".to_string());
102 scaffold_project(&cwd, &resolved_name)
103}
104
105fn scaffold_project(project_dir: &Path, name: &str) -> i32 {
107 let mut created: Vec<String> = Vec::new();
108
109 let manifest_path = project_dir.join(MANIFEST_FILE);
110 if !manifest_path.exists() {
111 let m = default_manifest_for(name);
112 let s = match m.to_toml_string() {
113 Ok(s) => s,
114 Err(e) => {
115 eprintln!("s init: {}", e);
116 return 1;
117 }
118 };
119 if let Err(e) = std::fs::write(&manifest_path, s) {
120 eprintln!("s init: write {}: {}", manifest_path.display(), e);
121 return 1;
122 }
123 created.push(manifest_path.display().to_string());
124 }
125
126 let main_path = project_dir.join("main.stk");
127 if !main_path.exists() {
128 let body = format!("#!/usr/bin/env stryke\n\np \"hello from {}!\"\n", name);
129 if let Err(e) = std::fs::write(&main_path, body) {
130 eprintln!("s init: write {}: {}", main_path.display(), e);
131 return 1;
132 }
133 created.push(main_path.display().to_string());
134 }
135
136 for sub in ["lib", "t", "benches", "bin", "examples"] {
137 let d = project_dir.join(sub);
138 if !d.exists() {
139 if let Err(e) = std::fs::create_dir_all(&d) {
140 eprintln!("s init: mkdir {}: {}", d.display(), e);
141 return 1;
142 }
143 created.push(format!("{}/", d.display()));
144 }
145 }
146
147 let test_path = project_dir.join("t/test_main.stk");
148 if !test_path.exists() {
149 let body = "#!/usr/bin/env stryke\n\nuse Test\n\nok 1, \"it works\"\n\ndone_testing()\n";
150 if let Err(e) = std::fs::write(&test_path, body) {
151 eprintln!("s init: write {}: {}", test_path.display(), e);
152 return 1;
153 }
154 created.push(test_path.display().to_string());
155 }
156
157 let gi = project_dir.join(".gitignore");
158 if !gi.exists() {
159 let body = "# stryke build artifacts\n/target/\n";
160 if let Err(e) = std::fs::write(&gi, body) {
161 eprintln!("s init: write {}: {}", gi.display(), e);
162 return 1;
163 }
164 created.push(gi.display().to_string());
165 }
166
167 for c in &created {
168 eprintln!(" created {}", c);
169 }
170 eprintln!("\x1b[32m✓ Initialized stryke project `{}`\x1b[0m", name);
171 eprintln!();
172 eprintln!(" s install # populate stryke.lock from stryke.toml");
173 eprintln!(" s run # run main.stk");
174 eprintln!(" s test # run tests in t/");
175 0
176}
177
178fn default_manifest_for(name: &str) -> Manifest {
179 let mut bin = IndexMap::new();
180 bin.insert(name.to_string(), "main.stk".to_string());
181 Manifest {
182 package: Some(PackageMeta {
183 name: name.to_string(),
184 version: "0.1.0".to_string(),
185 description: String::new(),
186 authors: Vec::new(),
187 license: String::new(),
188 repository: String::new(),
189 edition: "2026".to_string(),
190 }),
191 bin,
192 ..Manifest::default()
193 }
194}
195
196pub fn cmd_add(args: &[String]) -> i32 {
200 if args.iter().any(|a| is_help_flag(a)) {
201 println!(
202 "usage: stryke add SPEC [--dev | --group=NAME] [--path=DIR] [--features=A,B]"
203 );
204 println!();
205 println!("Add a dependency to stryke.toml and run `s install` to refresh stryke.lock.");
206 println!();
207 println!("SPEC may be one of:");
208 println!(" NAME[@VER] registry dep");
209 println!(" github.com/OWNER/REPO[@TAG] github-release dep (prebuilt tarball)");
210 println!(" https://github.com/OWNER/REPO[.git][@TAG] github-release dep (full URL form)");
211 println!(" ./PATH | ../PATH | /ABS/PATH | ~/PATH local path dep");
212 println!(" EXISTING_DIRECTORY local path dep (auto-detected)");
213 println!();
214 println!("`s install` downloads github-release deps as prebuilt tarballs from");
215 println!("the repo's GitHub Releases (host-triple asset, SHA-256 verified). This");
216 println!("is the path FFI cdylib packages (stryke-arrow, stryke-aws, ...) take.");
217 println!("For source-only git deps (no [ffi], no release artifacts), write the");
218 println!("`{{ git = \"...\" }}` form directly in stryke.toml.");
219 println!();
220 println!("Flags:");
221 println!(" --dev add as a [dev-deps] entry instead of [deps]");
222 println!(" --group=NAME add to [groups.NAME] (bundler-style)");
223 println!(" --path=DIR depend on a local checkout (no registry needed)");
224 println!(" --features=A,B enable feature flags A and B for this dep");
225 println!();
226 println!("Examples:");
227 println!(" stryke add http@1.0");
228 println!(" stryke add test-utils --dev");
229 println!(" stryke add criterion --group=bench");
230 println!(" stryke add mylib --path=../mylib");
231 println!(" stryke add github.com/MenkeTechnologies/stryke-parquet");
232 println!(" stryke add github.com/MenkeTechnologies/stryke-aws@v0.2.0");
233 println!(" stryke add ../sibling-pkg");
234 println!(" stryke add /work/vendored/mylib");
235 return 0;
236 }
237 let parsed = match parse_add_args(args) {
238 Ok(p) => p,
239 Err(msg) => {
240 eprintln!("s add: {}", msg);
241 return 1;
242 }
243 };
244
245 let cwd = match std::env::current_dir() {
246 Ok(c) => c,
247 Err(e) => {
248 eprintln!("s add: cwd: {}", e);
249 return 1;
250 }
251 };
252 let root = match find_project_root(&cwd) {
253 Some(r) => r,
254 None => {
255 eprintln!("s add: no stryke.toml found in this directory or any parent");
256 return 1;
257 }
258 };
259
260 let manifest_path = root.join(MANIFEST_FILE);
261 let mut manifest = match Manifest::from_path(&manifest_path) {
262 Ok(m) => m,
263 Err(e) => {
264 eprintln!("s add: {}", e);
265 return 1;
266 }
267 };
268
269 let target_map: &mut IndexMap<String, DepSpec> = match &parsed.kind {
270 AddKind::Runtime => &mut manifest.deps,
271 AddKind::Dev => &mut manifest.dev_deps,
272 AddKind::Group(g) => manifest.groups.entry(g.clone()).or_default(),
273 };
274 target_map.insert(parsed.name.clone(), parsed.spec.clone());
275
276 let body = match manifest.to_toml_string() {
277 Ok(s) => s,
278 Err(e) => {
279 eprintln!("s add: {}", e);
280 return 1;
281 }
282 };
283 if let Err(e) = std::fs::write(&manifest_path, body) {
284 eprintln!("s add: write {}: {}", manifest_path.display(), e);
285 return 1;
286 }
287 eprintln!(
288 " added {}{} = {}",
289 parsed.name,
290 match &parsed.kind {
291 AddKind::Runtime => "".to_string(),
292 AddKind::Dev => " (dev)".to_string(),
293 AddKind::Group(g) => format!(" (group:{})", g),
294 },
295 format_dep_for_log(&parsed.spec)
296 );
297
298 cmd_install(&[])
301}
302
303struct AddArgs {
304 name: String,
305 spec: DepSpec,
306 kind: AddKind,
307}
308
309enum AddKind {
310 Runtime,
311 Dev,
312 Group(String),
313}
314
315fn parse_add_args(args: &[String]) -> Result<AddArgs, String> {
316 if args.is_empty() {
317 return Err("usage: s add NAME[@VER] [--dev|--group=NAME] [--path=DIR]".into());
318 }
319 let mut positional: Vec<&String> = Vec::new();
320 let mut kind = AddKind::Runtime;
321 let mut path_override: Option<String> = None;
322 let mut features: Vec<String> = Vec::new();
323 for a in args {
324 match a.as_str() {
325 "--dev" => kind = AddKind::Dev,
326 s if s.starts_with("--group=") => {
327 kind = AddKind::Group(s["--group=".len()..].to_string())
328 }
329 s if s.starts_with("--path=") => path_override = Some(s["--path=".len()..].to_string()),
330 s if s.starts_with("--features=") => {
331 features = s["--features=".len()..]
332 .split(',')
333 .map(|s| s.trim().to_string())
334 .filter(|s| !s.is_empty())
335 .collect();
336 }
337 s if s.starts_with("--") => {
338 return Err(format!("unknown flag {}", s));
339 }
340 _ => positional.push(a),
341 }
342 }
343 if positional.len() != 1 {
344 return Err(format!(
345 "expected exactly one NAME[@VER] argument, got {}",
346 positional.len()
347 ));
348 }
349 let raw = positional[0].as_str();
350
351 if let Some(gh) = parse_github_shorthand(raw) {
365 let spec = if let Some(p) = path_override {
366 DepSpec::Detailed(DetailedDep {
367 path: Some(p),
368 version: gh.tag.clone(),
369 features,
370 default_features: true,
371 ..DetailedDep::default()
372 })
373 } else {
374 DepSpec::Detailed(DetailedDep {
375 github: Some(gh.owner_repo),
376 version: gh.tag,
377 features,
378 default_features: true,
379 ..DetailedDep::default()
380 })
381 };
382 return Ok(AddArgs {
383 name: gh.name,
384 spec,
385 kind,
386 });
387 }
388
389 if path_override.is_none() {
397 if let Some(local) = parse_local_path_arg(raw) {
398 let spec = DepSpec::Detailed(DetailedDep {
399 path: Some(local.path_for_manifest),
400 features,
401 default_features: true,
402 ..DetailedDep::default()
403 });
404 return Ok(AddArgs {
405 name: local.name,
406 spec,
407 kind,
408 });
409 }
410 }
411
412 let (name, version) = match raw.split_once('@') {
413 Some((n, v)) => (n.to_string(), Some(v.to_string())),
414 None => (raw.to_string(), None),
415 };
416 let spec = if let Some(p) = path_override {
417 DepSpec::Detailed(DetailedDep {
418 path: Some(p),
419 version,
420 features,
421 default_features: true,
422 ..DetailedDep::default()
423 })
424 } else if !features.is_empty() {
425 DepSpec::Detailed(DetailedDep {
426 version: Some(version.clone().unwrap_or_else(|| "*".to_string())),
427 features,
428 default_features: true,
429 ..DetailedDep::default()
430 })
431 } else {
432 DepSpec::Version(version.unwrap_or_else(|| "*".to_string()))
433 };
434 Ok(AddArgs { name, spec, kind })
435}
436
437struct GithubShorthand {
438 name: String,
441 owner_repo: String,
444 tag: Option<String>,
447}
448
449struct LocalPathArg {
450 name: String,
451 path_for_manifest: String,
457}
458
459fn parse_local_path_arg(raw: &str) -> Option<LocalPathArg> {
468 if raw.contains('@') || raw.contains(':') {
472 return None;
473 }
474
475 let sigil_path = raw.starts_with("./")
476 || raw.starts_with("../")
477 || raw.starts_with('/')
478 || raw.starts_with("~/")
479 || raw == "."
480 || raw == "..";
481
482 let expanded: String = if raw == "~" {
486 std::env::var("HOME").ok()?
487 } else if let Some(rest) = raw.strip_prefix("~/") {
488 let home = std::env::var("HOME").ok()?;
489 format!("{}/{}", home.trim_end_matches('/'), rest)
490 } else {
491 raw.to_string()
492 };
493
494 let candidate = Path::new(&expanded);
495 let exists_as_dir = candidate.is_dir();
496
497 if !sigil_path && !exists_as_dir {
504 return None;
505 }
506
507 let derived_name = candidate
510 .file_name()
511 .and_then(|s| s.to_str())
512 .filter(|s| !s.is_empty())
513 .map(|s| s.to_string())
514 .or_else(|| {
517 candidate
518 .canonicalize()
519 .ok()
520 .and_then(|c| c.file_name().map(|s| s.to_string_lossy().into_owned()))
521 })?;
522
523 let name = if exists_as_dir {
524 let manifest_path = candidate.join(MANIFEST_FILE);
525 if manifest_path.is_file() {
526 Manifest::from_path(&manifest_path)
527 .ok()
528 .and_then(|m| m.package.map(|p| p.name))
529 .unwrap_or(derived_name)
530 } else {
531 derived_name
532 }
533 } else {
534 derived_name
535 };
536
537 let path_for_manifest = if raw.starts_with("~/") || raw == "~" {
541 expanded
542 } else {
543 raw.to_string()
544 };
545
546 Some(LocalPathArg {
547 name,
548 path_for_manifest,
549 })
550}
551
552fn parse_github_shorthand(raw: &str) -> Option<GithubShorthand> {
556 let stripped = raw
557 .strip_prefix("https://")
558 .or_else(|| raw.strip_prefix("http://"))
559 .unwrap_or(raw);
560 let body = stripped.strip_prefix("github.com/")?;
561 let mut parts = body.splitn(2, '/');
565 let owner = parts.next()?;
566 let remainder = parts.next()?;
567 if owner.is_empty() || remainder.is_empty() || owner.contains('@') {
568 return None;
569 }
570 if remainder.contains('/') {
573 return None;
574 }
575 let (repo_with_suffix, tag) = match remainder.split_once('@') {
576 Some((r, t)) if !t.is_empty() => (r, Some(t.to_string())),
577 Some((_, _)) => return None, None => (remainder, None),
579 };
580 let repo = repo_with_suffix.strip_suffix(".git").unwrap_or(repo_with_suffix);
581 if repo.is_empty() {
582 return None;
583 }
584 Some(GithubShorthand {
585 name: repo.to_string(),
586 owner_repo: format!("{}/{}", owner, repo),
587 tag,
588 })
589}
590
591fn format_dep_for_log(spec: &DepSpec) -> String {
592 match spec {
593 DepSpec::Version(v) => format!("\"{}\"", v),
594 DepSpec::Detailed(d) => {
595 let mut bits = Vec::new();
596 if let Some(v) = &d.version {
597 bits.push(format!("version = \"{}\"", v));
598 }
599 if let Some(p) = &d.path {
600 bits.push(format!("path = \"{}\"", p));
601 }
602 if let Some(g) = &d.git {
603 bits.push(format!("git = \"{}\"", g));
604 }
605 if let Some(gh) = &d.github {
606 bits.push(format!("github = \"{}\"", gh));
607 }
608 if let Some(b) = &d.branch {
609 bits.push(format!("branch = \"{}\"", b));
610 }
611 if let Some(t) = &d.tag {
612 bits.push(format!("tag = \"{}\"", t));
613 }
614 if let Some(r) = &d.rev {
615 bits.push(format!("rev = \"{}\"", r));
616 }
617 if !d.features.is_empty() {
618 bits.push(format!("features = {:?}", d.features));
619 }
620 format!("{{ {} }}", bits.join(", "))
621 }
622 DepSpec::Placeholder => "<placeholder>".into(),
623 }
624}
625
626pub fn cmd_remove(args: &[String]) -> i32 {
628 if args.iter().any(|a| is_help_flag(a)) {
629 println!("usage: stryke remove NAME");
630 println!();
631 println!("Drop NAME from stryke.toml ([deps], [dev-deps], or [groups.*]) and");
632 println!("rerun `s install` so stryke.lock matches.");
633 return 0;
634 }
635 if args.len() != 1 {
636 eprintln!("usage: s remove NAME");
637 return 1;
638 }
639 let name = &args[0];
640 let cwd = match std::env::current_dir() {
641 Ok(c) => c,
642 Err(e) => {
643 eprintln!("s remove: cwd: {}", e);
644 return 1;
645 }
646 };
647 let root = match find_project_root(&cwd) {
648 Some(r) => r,
649 None => {
650 eprintln!("s remove: no stryke.toml found in this directory or any parent");
651 return 1;
652 }
653 };
654 let manifest_path = root.join(MANIFEST_FILE);
655 let mut manifest = match Manifest::from_path(&manifest_path) {
656 Ok(m) => m,
657 Err(e) => {
658 eprintln!("s remove: {}", e);
659 return 1;
660 }
661 };
662 let mut removed = false;
663 if manifest.deps.shift_remove(name).is_some() {
664 removed = true;
665 }
666 if manifest.dev_deps.shift_remove(name).is_some() {
667 removed = true;
668 }
669 for (_g, group_map) in manifest.groups.iter_mut() {
670 if group_map.shift_remove(name).is_some() {
671 removed = true;
672 }
673 }
674 if !removed {
675 eprintln!("s remove: `{}` is not a direct dep", name);
676 return 1;
677 }
678 let body = match manifest.to_toml_string() {
679 Ok(s) => s,
680 Err(e) => {
681 eprintln!("s remove: {}", e);
682 return 1;
683 }
684 };
685 if let Err(e) = std::fs::write(&manifest_path, body) {
686 eprintln!("s remove: write {}: {}", manifest_path.display(), e);
687 return 1;
688 }
689 eprintln!(" removed {}", name);
690 cmd_install(&[])
691}
692
693pub fn cmd_install(args: &[String]) -> i32 {
697 if args.iter().any(|a| is_help_flag(a)) {
698 println!("usage: stryke install [--offline]");
699 println!();
700 println!("Resolve manifest deps, install path/workspace deps into ~/.stryke/store/,");
701 println!("and write stryke.lock with deterministic ordering + SHA-256 integrity hashes.");
702 println!();
703 println!("Flags:");
704 println!(" --offline only use cached packages; never fetch from the network");
705 return 0;
706 }
707 let _offline = args.iter().any(|a| a == "--offline");
708
709 let cwd = match std::env::current_dir() {
710 Ok(c) => c,
711 Err(e) => {
712 eprintln!("s install: cwd: {}", e);
713 return 1;
714 }
715 };
716 let root = match find_project_root(&cwd) {
717 Some(r) => r,
718 None => {
719 eprintln!("s install: no stryke.toml found in this directory or any parent");
720 return 1;
721 }
722 };
723
724 let manifest_path = root.join(MANIFEST_FILE);
725 let manifest = match Manifest::from_path(&manifest_path) {
726 Ok(m) => m,
727 Err(e) => {
728 eprintln!("s install: {}", e);
729 return 1;
730 }
731 };
732 if let Err(e) = manifest.validate() {
733 eprintln!("s install: {}", e);
734 return 1;
735 }
736
737 let store = match Store::user_default() {
738 Ok(s) => s,
739 Err(e) => {
740 eprintln!("s install: {}", e);
741 return 1;
742 }
743 };
744
745 let r = Resolver {
746 manifest: &manifest,
747 manifest_dir: &root,
748 store: &store,
749 };
750 let outcome = match r.resolve() {
751 Ok(o) => o,
752 Err(e) => {
753 eprintln!("s install: {}", e);
754 return 1;
755 }
756 };
757
758 if outcome.installed.is_empty() {
759 eprintln!(" no deps to install");
760 } else {
761 for (name, version, _path) in &outcome.installed {
762 eprintln!(" installed {}@{}", name, version);
763 }
764 }
765
766 let mut lf = outcome.lockfile;
767 let body = match lf.to_toml_string() {
768 Ok(s) => s,
769 Err(e) => {
770 eprintln!("s install: {}", e);
771 return 1;
772 }
773 };
774 let lock_path = root.join(LOCKFILE_FILE);
775 if let Err(e) = std::fs::write(&lock_path, body) {
776 eprintln!("s install: write {}: {}", lock_path.display(), e);
777 return 1;
778 }
779 eprintln!(
780 "\x1b[32m✓ wrote {} ({} package{})\x1b[0m",
781 lock_path.display(),
782 lf.packages.len(),
783 if lf.packages.len() == 1 { "" } else { "s" }
784 );
785
786 0
791}
792
793pub fn cmd_tree(args: &[String]) -> i32 {
797 if args.iter().any(|a| is_help_flag(a)) {
798 println!("usage: stryke tree");
799 println!();
800 println!("Print the resolved dependency graph from stryke.lock as a tree, with the");
801 println!("project at the root and direct + transitive deps underneath.");
802 println!();
803 println!("Run `s install` first to generate stryke.lock.");
804 return 0;
805 }
806 let cwd = match std::env::current_dir() {
807 Ok(c) => c,
808 Err(e) => {
809 eprintln!("s tree: cwd: {}", e);
810 return 1;
811 }
812 };
813 let root = match find_project_root(&cwd) {
814 Some(r) => r,
815 None => {
816 eprintln!("s tree: no stryke.toml found");
817 return 1;
818 }
819 };
820 let manifest = match Manifest::from_path(&root.join(MANIFEST_FILE)) {
821 Ok(m) => m,
822 Err(e) => {
823 eprintln!("s tree: {}", e);
824 return 1;
825 }
826 };
827 let lock_path = root.join(LOCKFILE_FILE);
828 if !lock_path.is_file() {
829 eprintln!("s tree: stryke.lock not found — run `s install` first");
830 return 1;
831 }
832 let lock = match Lockfile::from_path(&lock_path) {
833 Ok(l) => l,
834 Err(e) => {
835 eprintln!("s tree: {}", e);
836 return 1;
837 }
838 };
839
840 let pkg_label = manifest
841 .package
842 .as_ref()
843 .map(|p| format!("{} v{}", p.name, p.version))
844 .unwrap_or_else(|| "(workspace)".to_string());
845 println!("{}", pkg_label);
846
847 let direct_names: Vec<String> = manifest
848 .deps
849 .keys()
850 .chain(manifest.dev_deps.keys())
851 .chain(manifest.groups.values().flat_map(|g| g.keys()))
852 .cloned()
853 .collect();
854
855 for (i, dep_name) in direct_names.iter().enumerate() {
856 let last = i + 1 == direct_names.len();
857 print_tree_entry(&lock, dep_name, "", last);
858 }
859 0
860}
861
862fn print_tree_entry(lock: &Lockfile, name: &str, prefix: &str, last: bool) {
863 let connector = if last { "└── " } else { "├── " };
864 let next_prefix = if last { " " } else { "│ " };
865 match lock.find(name) {
866 Some(entry) => {
867 println!("{}{}{} v{}", prefix, connector, entry.name, entry.version);
868 for (i, dep_pin) in entry.deps.iter().enumerate() {
869 let dep_name = dep_pin.split_once('@').map(|(n, _)| n).unwrap_or(dep_pin);
870 let last_child = i + 1 == entry.deps.len();
871 print_tree_entry(
872 lock,
873 dep_name,
874 &format!("{}{}", prefix, next_prefix),
875 last_child,
876 );
877 }
878 }
879 None => {
880 println!("{}{}{} (not in lockfile)", prefix, connector, name);
881 }
882 }
883}
884
885pub fn cmd_info(args: &[String]) -> i32 {
889 if args.iter().any(|a| is_help_flag(a)) {
890 println!("usage: stryke info NAME");
891 println!();
892 println!("Print the lockfile entry and store path for an installed dep. Shows name,");
893 println!("version, source URL, integrity hash, enabled features, and transitive deps.");
894 println!();
895 println!("Run `s install` first to generate stryke.lock.");
896 return 0;
897 }
898 if args.len() != 1 {
899 eprintln!("usage: s info NAME");
900 return 1;
901 }
902 let name = &args[0];
903 let cwd = match std::env::current_dir() {
904 Ok(c) => c,
905 Err(e) => {
906 eprintln!("s info: cwd: {}", e);
907 return 1;
908 }
909 };
910 let root = match find_project_root(&cwd) {
911 Some(r) => r,
912 None => {
913 eprintln!("s info: no stryke.toml found");
914 return 1;
915 }
916 };
917 let lock_path = root.join(LOCKFILE_FILE);
918 if !lock_path.is_file() {
919 eprintln!("s info: stryke.lock not found — run `s install` first");
920 return 1;
921 }
922 let lock = match Lockfile::from_path(&lock_path) {
923 Ok(l) => l,
924 Err(e) => {
925 eprintln!("s info: {}", e);
926 return 1;
927 }
928 };
929 let entry = match lock.find(name) {
930 Some(e) => e,
931 None => {
932 eprintln!("s info: `{}` is not in stryke.lock", name);
933 return 1;
934 }
935 };
936 let store = match Store::user_default() {
937 Ok(s) => s,
938 Err(e) => {
939 eprintln!("s info: {}", e);
940 return 1;
941 }
942 };
943 let pkg_dir = store.package_dir(&entry.name, &entry.version);
944 println!("name: {}", entry.name);
945 println!("version: {}", entry.version);
946 println!("source: {}", entry.source);
947 println!("integrity: {}", entry.integrity);
948 if !entry.features.is_empty() {
949 println!("features: {}", entry.features.join(", "));
950 }
951 if !entry.deps.is_empty() {
952 println!("deps: {}", entry.deps.join(", "));
953 }
954 println!("store path: {}", pkg_dir.display());
955 let nested_manifest = pkg_dir.join(MANIFEST_FILE);
956 if nested_manifest.is_file() {
957 if let Ok(m) = Manifest::from_path(&nested_manifest) {
958 if let Some(meta) = &m.package {
959 if !meta.description.is_empty() {
960 println!("description: {}", meta.description);
961 }
962 if !meta.license.is_empty() {
963 println!("license: {}", meta.license);
964 }
965 if !meta.repository.is_empty() {
966 println!("repo: {}", meta.repository);
967 }
968 }
969 }
970 }
971 0
972}
973
974pub fn load_project(root: &Path) -> PkgResult<(Manifest, Option<Lockfile>)> {
977 let manifest = Manifest::from_path(&root.join(MANIFEST_FILE))?;
978 let lock_path = root.join(LOCKFILE_FILE);
979 let lockfile = if lock_path.is_file() {
980 Some(Lockfile::from_path(&lock_path)?)
981 } else {
982 None
983 };
984 Ok((manifest, lockfile))
985}
986
987pub fn resolve_module(
1008 root: &Path,
1009 logical_name: &str,
1010 pin_version: Option<&str>,
1011) -> PkgResult<Option<PathBuf>> {
1012 let segments: Vec<&str> = logical_name.split("::").collect();
1013 if segments.is_empty() {
1014 return Ok(None);
1015 }
1016
1017 let local = root.join("lib").join(segments_to_path(&segments));
1031 if local.is_file() {
1032 let _ = try_load_ffi_for(root);
1033 return Ok(Some(local));
1034 }
1035
1036 if segments.len() > 1 {
1051 if let Ok(manifest) = Manifest::from_path(&root.join(MANIFEST_FILE)) {
1052 let seg0_lc = segments[0].to_lowercase();
1053 let ns_match = manifest
1054 .ffi
1055 .as_ref()
1056 .map(|f| !f.namespace.is_empty() && f.namespace.eq_ignore_ascii_case(segments[0]))
1057 .unwrap_or(false);
1058 let pkg_name_match = manifest
1059 .package
1060 .as_ref()
1061 .map(|p| {
1062 let n = p.name.to_lowercase();
1063 n == seg0_lc || n == format!("stryke-{}", seg0_lc)
1064 })
1065 .unwrap_or(false);
1066 if ns_match || pkg_name_match {
1067 let flat = root.join("lib").join(segments_to_path(&segments[1..]));
1068 if flat.is_file() {
1069 let _ = try_load_ffi_for(root);
1070 return Ok(Some(flat));
1071 }
1072 }
1073 }
1074 }
1075
1076 if let Some(v) = pin_version {
1081 let store = Store::user_default()?;
1082 let pkg_name_lower = segments[0].to_lowercase();
1083 let names_to_try = [pkg_name_lower.clone(), format!("stryke-{}", pkg_name_lower)];
1084 let mut tried: Vec<PathBuf> = Vec::with_capacity(names_to_try.len());
1085 for nm in names_to_try.iter() {
1086 let store_pkg = store.package_dir(nm, v);
1087 tried.push(store_pkg.clone());
1088 let nested_path = if segments.len() == 1 {
1089 store_pkg.join("lib").join(format!("{}.stk", segments[0]))
1090 } else {
1091 store_pkg.join("lib").join(segments_to_path(&segments[1..]))
1092 };
1093 if nested_path.is_file() {
1094 let _ = try_load_ffi_for(&store_pkg);
1095 return Ok(Some(nested_path));
1096 }
1097 }
1098 let probed = tried
1099 .iter()
1100 .map(|p| p.display().to_string())
1101 .collect::<Vec<_>>()
1102 .join(", ");
1103 return Err(PkgError::Other(format!(
1104 "use {} {}: pinned version not in store (tried: {})",
1105 logical_name, v, probed
1106 )));
1107 }
1108
1109 let lock_path = root.join(LOCKFILE_FILE);
1114 if lock_path.is_file() {
1115 let lock = Lockfile::from_path(&lock_path)?;
1116 let pkg_name = segments[0].to_lowercase();
1117 let entry = lock
1118 .find(&pkg_name)
1119 .or_else(|| lock.find(&format!("stryke-{}", pkg_name)));
1120 if let Some(entry) = entry {
1121 let store = Store::user_default()?;
1122 for store_pkg in resolve_store_candidates(&store, &entry.name, &entry.version) {
1129 let nested_path = if segments.len() == 1 {
1130 store_pkg.join("lib").join(format!("{}.stk", segments[0]))
1131 } else {
1132 store_pkg.join("lib").join(segments_to_path(&segments[1..]))
1133 };
1134 if nested_path.is_file() {
1135 let _ = try_load_ffi_for(&store_pkg);
1136 return Ok(Some(nested_path));
1137 }
1138 }
1139 return Err(PkgError::Other(format!(
1145 "use {}: stryke.lock pins {}@{} but store has no extraction at that version \
1146 (run `s install`)",
1147 logical_name, entry.name, entry.version
1148 )));
1149 }
1150 }
1151
1152 let store = Store::user_default()?;
1167 let canonical_names = canonical_store_names_for_namespace(&store, &segments[0].to_lowercase());
1168 if let Some((name, version)) = scan_store_for_highest_version(&store, &canonical_names) {
1169 let store_pkg = store.package_dir(&name, &version);
1170 let nested_path = if segments.len() == 1 {
1171 store_pkg.join("lib").join(format!("{}.stk", segments[0]))
1172 } else {
1173 store_pkg.join("lib").join(segments_to_path(&segments[1..]))
1174 };
1175 if nested_path.is_file() {
1176 let _ = try_load_ffi_for(&store_pkg);
1177 return Ok(Some(nested_path));
1178 }
1179 }
1180
1181 Ok(None)
1182}
1183
1184pub(crate) fn canonical_store_names_for_namespace(store: &Store, namespace_lc: &str) -> Vec<String> {
1199 let mut names = Vec::new();
1200 names.push(namespace_lc.to_string());
1201 names.push(format!("stryke-{}", namespace_lc));
1202 if let Ok(idx) = InstalledIndex::load_from(store) {
1203 for pkg in &idx.packages {
1204 let name_lc = pkg.name.to_lowercase();
1205 let pkg_ns_lc = pkg.namespace.to_lowercase();
1206 if name_lc == namespace_lc
1207 || pkg_ns_lc == namespace_lc
1208 || name_lc == format!("stryke-{}", namespace_lc)
1209 {
1210 names.push(pkg.name.clone());
1211 }
1212 }
1213 }
1214 names.sort();
1215 names.dedup();
1216 names
1217}
1218
1219pub(crate) fn scan_store_for_highest_version(
1229 store: &Store,
1230 names: &[String],
1231) -> Option<(String, String)> {
1232 let store_dir = store.store_dir();
1233 let entries = std::fs::read_dir(&store_dir).ok()?;
1234 let mut best: Option<(String, String, VersionRank)> = None;
1235 for ent in entries.flatten() {
1236 let dirname = ent.file_name().to_string_lossy().into_owned();
1237 let Some((pkg, ver)) = dirname.rsplit_once('@') else {
1238 continue;
1239 };
1240 if !names.iter().any(|n| n == pkg) {
1241 continue;
1242 }
1243 let rank = VersionRank::parse(ver);
1244 let take = match &best {
1245 None => true,
1246 Some((_, _, cur)) => rank > *cur,
1247 };
1248 if take {
1249 best = Some((pkg.to_string(), ver.to_string(), rank));
1250 }
1251 }
1252 best.map(|(n, v, _)| (n, v))
1253}
1254
1255#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1273pub(crate) struct VersionRank {
1274 nums: Vec<u64>,
1276 is_release: u8,
1280 suffix_alpha: String,
1284 suffix_num: u64,
1287}
1288
1289impl VersionRank {
1290 pub(crate) fn parse(ver: &str) -> Self {
1291 let no_build = ver.split('+').next().unwrap_or(ver);
1295 let (prefix, suffix) = match no_build.split_once('-') {
1296 Some((p, s)) => (p, s),
1297 None => (no_build, ""),
1298 };
1299 let nums: Vec<u64> = prefix
1300 .split('.')
1301 .map(|p| {
1302 let head: String = p.chars().take_while(|c| c.is_ascii_digit()).collect();
1303 head.parse::<u64>().unwrap_or(0)
1304 })
1305 .collect();
1306 let is_release = if suffix.is_empty() { 1 } else { 0 };
1307 let alpha_end = suffix
1312 .char_indices()
1313 .find(|(_, c)| c.is_ascii_digit())
1314 .map(|(i, _)| i)
1315 .unwrap_or(suffix.len());
1316 let suffix_alpha = suffix[..alpha_end].to_string();
1317 let suffix_num = suffix[alpha_end..]
1318 .chars()
1319 .take_while(|c| c.is_ascii_digit())
1320 .collect::<String>()
1321 .parse::<u64>()
1322 .unwrap_or(0);
1323 Self {
1324 nums,
1325 is_release,
1326 suffix_alpha,
1327 suffix_num,
1328 }
1329 }
1330}
1331
1332fn resolve_store_candidates(
1338 store: &Store,
1339 name: &str,
1340 version: &str,
1341) -> Vec<std::path::PathBuf> {
1342 let mut out = Vec::with_capacity(2);
1343 out.push(store.package_dir(name, version));
1344 if !name.starts_with("stryke-") {
1345 out.push(store.package_dir(&format!("stryke-{}", name), version));
1346 }
1347 out
1348}
1349
1350fn try_load_ffi_for(pkg_dir: &Path) -> PkgResult<()> {
1355 let manifest_path = pkg_dir.join(MANIFEST_FILE);
1356 if !manifest_path.is_file() {
1357 return Ok(());
1358 }
1359 let manifest = Manifest::from_path(&manifest_path)?;
1360 let Some(ffi) = manifest.ffi else {
1361 return Ok(());
1362 };
1363 if ffi.lib_name.is_empty() || ffi.exports.is_empty() {
1364 return Ok(());
1365 }
1366 let lib_filename = format!(
1367 "{}{}{}",
1368 std::env::consts::DLL_PREFIX,
1369 ffi.lib_name,
1370 std::env::consts::DLL_SUFFIX
1371 );
1372 let candidates = [
1381 pkg_dir.join("lib").join(&lib_filename),
1382 pkg_dir.join("target").join("release").join(&lib_filename),
1383 pkg_dir.join("target").join("debug").join(&lib_filename),
1384 ];
1385 let lib_path = match candidates.iter().find(|p| p.is_file()) {
1386 Some(p) => p.clone(),
1387 None => {
1388 return Err(super::PkgError::Other(format!(
1389 "[ffi] cdylib `{}` not found under {}/lib or {}/target/{{release,debug}}/ \
1390 — install with `s pkg install -g github.com/...` to fetch the prebuilt \
1391 artifact, or run `cargo build --release` in the package dir for dev",
1392 lib_filename,
1393 pkg_dir.display(),
1394 pkg_dir.display()
1395 )));
1396 }
1397 };
1398 crate::rust_ffi::load_cdylib(&lib_path, &ffi.exports, 0)
1399 .map_err(|e| super::PkgError::Other(format!("[ffi] load {}: {}", lib_path.display(), e)))
1400}
1401
1402fn segments_to_path(segments: &[&str]) -> PathBuf {
1403 let mut p = PathBuf::new();
1404 for (i, seg) in segments.iter().enumerate() {
1405 if i + 1 == segments.len() {
1406 p.push(format!("{}.stk", seg));
1407 } else {
1408 p.push(seg);
1409 }
1410 }
1411 p
1412}
1413
1414pub fn cmd_clean(args: &[String]) -> i32 {
1420 if args.iter().any(|a| is_help_flag(a)) {
1421 println!("usage: stryke clean [--all]");
1422 println!();
1423 println!("Remove the local target/ directory and per-project bytecode cache.");
1424 println!();
1425 println!("Flags:");
1426 println!(" --all additionally clear ~/.stryke/cache/ and ~/.stryke/store/");
1427 return 0;
1428 }
1429 let want_global = args.iter().any(|a| a == "--all");
1430
1431 let cwd = match std::env::current_dir() {
1432 Ok(c) => c,
1433 Err(e) => {
1434 eprintln!("s clean: cwd: {}", e);
1435 return 1;
1436 }
1437 };
1438 let root = find_project_root(&cwd).unwrap_or(cwd);
1439 let mut wiped: Vec<String> = Vec::new();
1440 for sub in ["target", ".stryke-cache"] {
1441 let d = root.join(sub);
1442 if d.exists() {
1443 if let Err(e) = std::fs::remove_dir_all(&d) {
1444 eprintln!("s clean: remove {}: {}", d.display(), e);
1445 return 1;
1446 }
1447 wiped.push(d.display().to_string());
1448 }
1449 }
1450
1451 if want_global {
1452 if let Ok(store) = Store::user_default() {
1453 for d in [store.cache_dir(), store.store_dir(), store.git_dir()] {
1454 if d.exists() {
1455 if let Err(e) = std::fs::remove_dir_all(&d) {
1456 eprintln!("s clean: remove {}: {}", d.display(), e);
1457 return 1;
1458 }
1459 wiped.push(d.display().to_string());
1460 }
1461 }
1462 }
1463 }
1464
1465 if wiped.is_empty() {
1466 eprintln!(" nothing to clean");
1467 } else {
1468 for w in &wiped {
1469 eprintln!(" removed {}", w);
1470 }
1471 }
1472 0
1473}
1474
1475pub fn cmd_update(args: &[String]) -> i32 {
1480 if args.iter().any(|a| is_help_flag(a)) {
1481 println!("usage: stryke update [NAME]");
1482 println!();
1483 println!("Re-resolve the dependency graph and rewrite stryke.lock. With registry");
1484 println!("deps unwired, this currently re-pins path/workspace dep integrity hashes.");
1485 println!();
1486 println!("NAME: when given, only that dep is re-resolved (others stay pinned).");
1487 return 0;
1488 }
1489 let cwd = match std::env::current_dir() {
1490 Ok(c) => c,
1491 Err(e) => {
1492 eprintln!("s update: cwd: {}", e);
1493 return 1;
1494 }
1495 };
1496 let root = match find_project_root(&cwd) {
1497 Some(r) => r,
1498 None => {
1499 eprintln!("s update: no stryke.toml found");
1500 return 1;
1501 }
1502 };
1503 let lock_path = root.join(LOCKFILE_FILE);
1504 if lock_path.exists() {
1505 if let Err(e) = std::fs::remove_file(&lock_path) {
1506 eprintln!("s update: remove {}: {}", lock_path.display(), e);
1507 return 1;
1508 }
1509 }
1510 eprintln!(" re-resolving dependency graph");
1511 cmd_install(&[])
1512}
1513
1514pub fn cmd_upgrade_project(args: &[String]) -> i32 {
1527 if args.iter().any(|a| is_help_flag(a)) {
1528 println!("usage: stryke upgrade [NAME]");
1529 println!();
1530 println!("Move deps to their latest upstream versions and re-resolve. Unlike");
1531 println!("`s update` (re-resolve within existing constraints), this rewrites the");
1532 println!("stryke.toml pins themselves:");
1533 println!(" github deps pinned `version` bumped to the latest release tag");
1534 println!(" git deps pinned `tag` bumped (github-hosted URLs only)");
1535 println!(" path deps nothing to bump — they track their source dir");
1536 println!(" registry deps skipped until the registry endpoint is wired");
1537 println!();
1538 println!("NAME: when given, only that dep's pin is bumped (others stay).");
1539 println!("Outside a project, use `s upgrade -g` for global packages.");
1540 return 0;
1541 }
1542 let filter = args.iter().find(|a| !a.starts_with('-')).cloned();
1543 let cwd = match std::env::current_dir() {
1544 Ok(c) => c,
1545 Err(e) => {
1546 eprintln!("s upgrade: cwd: {}", e);
1547 return 1;
1548 }
1549 };
1550 let root = match find_project_root(&cwd) {
1551 Some(r) => r,
1552 None => {
1553 eprintln!("s upgrade: no stryke.toml found (use `s upgrade -g` for global packages)");
1554 return 1;
1555 }
1556 };
1557 let manifest_path = root.join(MANIFEST_FILE);
1558 let mut manifest = match Manifest::from_path(&manifest_path) {
1559 Ok(m) => m,
1560 Err(e) => {
1561 eprintln!("s upgrade: {}", e);
1562 return 1;
1563 }
1564 };
1565
1566 let mut bumped = 0u32;
1568 let mut failed = 0u32;
1569 let mut sections: Vec<&mut IndexMap<String, DepSpec>> =
1570 vec![&mut manifest.deps, &mut manifest.dev_deps];
1571 sections.extend(manifest.groups.values_mut());
1572 for section in sections {
1573 for (name, spec) in section.iter_mut() {
1574 if filter.as_deref().map_or(false, |f| name != f) {
1575 continue;
1576 }
1577 match bump_dep_pin(name, spec) {
1578 Ok(true) => bumped += 1,
1579 Ok(false) => {}
1580 Err(e) => {
1581 eprintln!(" \x1b[31m✗ {}: {}\x1b[0m", name, e);
1582 failed += 1;
1583 }
1584 }
1585 }
1586 }
1587
1588 if bumped > 0 {
1589 let body = match manifest.to_toml_string() {
1590 Ok(s) => s,
1591 Err(e) => {
1592 eprintln!("s upgrade: {}", e);
1593 return 1;
1594 }
1595 };
1596 if let Err(e) = std::fs::write(&manifest_path, body) {
1597 eprintln!("s upgrade: write {}: {}", manifest_path.display(), e);
1598 return 1;
1599 }
1600 }
1601 if failed > 0 {
1602 return 1;
1603 }
1604 cmd_update(&[])
1607}
1608
1609fn bump_dep_pin(name: &str, spec: &mut DepSpec) -> Result<bool, String> {
1613 let DepSpec::Detailed(d) = spec else {
1614 eprintln!(" - {} registry dep — skipped (registry not wired)", name);
1616 return Ok(false);
1617 };
1618 if let Some(owner_repo) = d.github.clone() {
1620 let Some(pinned) = d.version.clone() else {
1621 eprintln!(" ✓ {} unpinned github dep — floats to latest on re-resolve", name);
1622 return Ok(false);
1623 };
1624 let (owner, repo) = parse_gh_owner_repo(&owner_repo)?;
1625 let latest = fetch_latest_release_tag(&owner, &repo)?;
1626 if same_version(&latest, &pinned) {
1627 eprintln!(" ✓ {}@{} up to date", name, pinned);
1628 return Ok(false);
1629 }
1630 eprintln!(" \x1b[33m{} {} → {}\x1b[0m", name, pinned, latest);
1631 d.version = Some(latest);
1632 return Ok(true);
1633 }
1634 if let Some(url) = d.git.clone() {
1636 let Some((owner, repo)) = super::resolver::parse_github_url(&url) else {
1637 eprintln!(" - {} non-github git dep — skipped (no release API)", name);
1638 return Ok(false);
1639 };
1640 let Some(pinned) = d.tag.clone() else {
1641 eprintln!(" ✓ {} unpinned git dep — floats to latest on re-resolve", name);
1642 return Ok(false);
1643 };
1644 let latest = fetch_latest_release_tag(&owner, &repo)?;
1645 if same_version(&latest, &pinned) {
1646 eprintln!(" ✓ {}@{} up to date", name, pinned);
1647 return Ok(false);
1648 }
1649 eprintln!(" \x1b[33m{} {} → {}\x1b[0m", name, pinned, latest);
1650 d.tag = Some(latest);
1651 return Ok(true);
1652 }
1653 if d.path.is_some() || d.workspace {
1654 return Ok(false);
1656 }
1657 eprintln!(" - {} registry dep — skipped (registry not wired)", name);
1658 Ok(false)
1659}
1660
1661pub fn cmd_outdated(args: &[String]) -> i32 {
1666 if args.iter().any(|a| is_help_flag(a)) {
1667 println!("usage: stryke outdated");
1668 println!();
1669 println!("Show deps whose stryke.lock pin no longer matches the upstream state.");
1670 println!("Path deps: integrity hash recomputed against the source directory.");
1671 println!("Registry deps: not wired in this stryke version.");
1672 return 0;
1673 }
1674 let cwd = match std::env::current_dir() {
1675 Ok(c) => c,
1676 Err(e) => {
1677 eprintln!("s outdated: cwd: {}", e);
1678 return 1;
1679 }
1680 };
1681 let root = match find_project_root(&cwd) {
1682 Some(r) => r,
1683 None => {
1684 eprintln!("s outdated: no stryke.toml found");
1685 return 1;
1686 }
1687 };
1688 let manifest = match Manifest::from_path(&root.join(MANIFEST_FILE)) {
1689 Ok(m) => m,
1690 Err(e) => {
1691 eprintln!("s outdated: {}", e);
1692 return 1;
1693 }
1694 };
1695 let lock_path = root.join(LOCKFILE_FILE);
1696 if !lock_path.is_file() {
1697 eprintln!("s outdated: stryke.lock not found — run `s install` first");
1698 return 1;
1699 }
1700 let lock = match Lockfile::from_path(&lock_path) {
1701 Ok(l) => l,
1702 Err(e) => {
1703 eprintln!("s outdated: {}", e);
1704 return 1;
1705 }
1706 };
1707
1708 let mut drifted: Vec<String> = Vec::new();
1709 let mut registry_skipped: Vec<String> = Vec::new();
1710 for (name, spec) in manifest.deps.iter() {
1711 if let Some(p) = spec.path() {
1712 let abs = if std::path::Path::new(p).is_absolute() {
1713 std::path::PathBuf::from(p)
1714 } else {
1715 root.join(p)
1716 };
1717 if let Ok(now) = super::lockfile::integrity_for_directory(&abs) {
1718 if let Some(entry) = lock.find(name) {
1719 if entry.integrity != now {
1720 drifted.push(format!(
1721 " {}@{} pinned {} current {}",
1722 name, entry.version, entry.integrity, now
1723 ));
1724 }
1725 } else {
1726 drifted.push(format!(
1727 " {} (path) not in lockfile — run `s install`",
1728 name
1729 ));
1730 }
1731 }
1732 } else {
1733 registry_skipped.push(name.clone());
1734 }
1735 }
1736
1737 if drifted.is_empty() && registry_skipped.is_empty() {
1738 eprintln!("\x1b[32m✓ all path deps are up to date\x1b[0m");
1739 return 0;
1740 }
1741 if !drifted.is_empty() {
1742 eprintln!("path deps with drift (run `s install` to re-pin):");
1743 for d in &drifted {
1744 eprintln!("{}", d);
1745 }
1746 }
1747 if !registry_skipped.is_empty() {
1748 eprintln!(
1749 "registry deps skipped — wire protocol not deployed yet ({}): {}",
1750 registry_skipped.len(),
1751 registry_skipped.join(", ")
1752 );
1753 }
1754 0
1755}
1756
1757pub fn cmd_audit(args: &[String]) -> i32 {
1762 if args.iter().any(|a| is_help_flag(a)) {
1763 println!("usage: stryke audit [--fail-on=high|critical]");
1764 println!();
1765 println!("Check stryke.lock against a vulnerability advisory feed. The feed itself");
1766 println!("is not deployed yet — this command currently reports the dep count and");
1767 println!("emits an honest 'no advisories' message rather than faking it.");
1768 return 0;
1769 }
1770 let cwd = match std::env::current_dir() {
1771 Ok(c) => c,
1772 Err(e) => {
1773 eprintln!("s audit: cwd: {}", e);
1774 return 1;
1775 }
1776 };
1777 let root = match find_project_root(&cwd) {
1778 Some(r) => r,
1779 None => {
1780 eprintln!("s audit: no stryke.toml found");
1781 return 1;
1782 }
1783 };
1784 let lock_path = root.join(LOCKFILE_FILE);
1785 if !lock_path.is_file() {
1786 eprintln!("s audit: stryke.lock not found — run `s install` first");
1787 return 1;
1788 }
1789 let lock = match Lockfile::from_path(&lock_path) {
1790 Ok(l) => l,
1791 Err(e) => {
1792 eprintln!("s audit: {}", e);
1793 return 1;
1794 }
1795 };
1796 eprintln!(
1797 " audited {} package{}",
1798 lock.packages.len(),
1799 if lock.packages.len() == 1 { "" } else { "s" }
1800 );
1801 eprintln!("\x1b[33m advisory feed not yet deployed — no vulnerabilities reported\x1b[0m");
1802 0
1803}
1804
1805pub fn cmd_run_script(args: &[String]) -> i32 {
1813 if args.iter().any(|a| is_help_flag(a)) {
1814 println!("usage: stryke run SCRIPT [ARGS...]");
1815 println!();
1816 println!("Look up SCRIPT in the [scripts] table of stryke.toml and execute it via");
1817 println!("the system shell. Any ARGS are appended to the script command line.");
1818 println!();
1819 println!("Without [scripts], `stryke run` falls back to running ./main.stk directly.");
1820 return 0;
1821 }
1822 if args.is_empty() {
1823 eprintln!("usage: s run SCRIPT [ARGS...]");
1824 return 1;
1825 }
1826 let script = &args[0];
1827 let cwd = match std::env::current_dir() {
1828 Ok(c) => c,
1829 Err(e) => {
1830 eprintln!("s run: cwd: {}", e);
1831 return 1;
1832 }
1833 };
1834 let root = match find_project_root(&cwd) {
1835 Some(r) => r,
1836 None => {
1837 eprintln!("s run: no stryke.toml found in this directory or any parent");
1838 return 1;
1839 }
1840 };
1841 let manifest = match Manifest::from_path(&root.join(MANIFEST_FILE)) {
1842 Ok(m) => m,
1843 Err(e) => {
1844 eprintln!("s run: {}", e);
1845 return 1;
1846 }
1847 };
1848 let cmd = match manifest.scripts.get(script) {
1849 Some(c) => c.clone(),
1850 None => {
1851 eprintln!("s run: no script `{}` in [scripts]", script);
1852 if !manifest.scripts.is_empty() {
1853 eprintln!(
1854 "available: {}",
1855 manifest
1856 .scripts
1857 .keys()
1858 .cloned()
1859 .collect::<Vec<_>>()
1860 .join(", ")
1861 );
1862 }
1863 return 1;
1864 }
1865 };
1866 let extra = &args[1..];
1867 let full = if extra.is_empty() {
1868 cmd.clone()
1869 } else {
1870 format!(
1871 "{} {}",
1872 cmd,
1873 extra
1874 .iter()
1875 .map(|a| shell_escape_simple(a))
1876 .collect::<Vec<_>>()
1877 .join(" ")
1878 )
1879 };
1880 eprintln!(" $ {}", full);
1881 let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
1882 let status = std::process::Command::new(&shell)
1883 .arg("-c")
1884 .arg(&full)
1885 .current_dir(&root)
1886 .status();
1887 match status {
1888 Ok(s) => s.code().unwrap_or(1),
1889 Err(e) => {
1890 eprintln!("s run: spawn {}: {}", shell, e);
1891 1
1892 }
1893 }
1894}
1895
1896fn shell_escape_simple(s: &str) -> String {
1898 if !s.contains(' ')
1899 && !s.contains('\'')
1900 && !s.contains('"')
1901 && !s.contains('$')
1902 && !s.contains('`')
1903 {
1904 return s.to_string();
1905 }
1906 let escaped = s.replace('\'', "'\\''");
1907 format!("'{}'", escaped)
1908}
1909
1910pub fn cmd_vendor(args: &[String]) -> i32 {
1915 if args.iter().any(|a| is_help_flag(a)) {
1916 println!("usage: stryke vendor");
1917 println!();
1918 println!("Copy every dep in stryke.lock from ~/.stryke/store/ into ./vendor/ so");
1919 println!("the project is offline-buildable. Useful for shipping a tarball that");
1920 println!("builds without registry access.");
1921 return 0;
1922 }
1923 let cwd = match std::env::current_dir() {
1924 Ok(c) => c,
1925 Err(e) => {
1926 eprintln!("s vendor: cwd: {}", e);
1927 return 1;
1928 }
1929 };
1930 let root = match find_project_root(&cwd) {
1931 Some(r) => r,
1932 None => {
1933 eprintln!("s vendor: no stryke.toml found");
1934 return 1;
1935 }
1936 };
1937 let lock_path = root.join(LOCKFILE_FILE);
1938 if !lock_path.is_file() {
1939 eprintln!("s vendor: stryke.lock not found — run `s install` first");
1940 return 1;
1941 }
1942 let lock = match Lockfile::from_path(&lock_path) {
1943 Ok(l) => l,
1944 Err(e) => {
1945 eprintln!("s vendor: {}", e);
1946 return 1;
1947 }
1948 };
1949 let store = match Store::user_default() {
1950 Ok(s) => s,
1951 Err(e) => {
1952 eprintln!("s vendor: {}", e);
1953 return 1;
1954 }
1955 };
1956
1957 let vendor_dir = root.join("vendor");
1958 if vendor_dir.exists() {
1959 if let Err(e) = std::fs::remove_dir_all(&vendor_dir) {
1960 eprintln!("s vendor: clear {}: {}", vendor_dir.display(), e);
1961 return 1;
1962 }
1963 }
1964 if let Err(e) = std::fs::create_dir_all(&vendor_dir) {
1965 eprintln!("s vendor: mkdir {}: {}", vendor_dir.display(), e);
1966 return 1;
1967 }
1968
1969 let mut copied = 0_usize;
1970 for pkg in &lock.packages {
1971 let src = store.package_dir(&pkg.name, &pkg.version);
1972 if !src.is_dir() {
1973 eprintln!(
1974 "s vendor: {}@{} not in store — run `s install` first",
1975 pkg.name, pkg.version
1976 );
1977 return 1;
1978 }
1979 let dst = vendor_dir.join(format!("{}@{}", pkg.name, pkg.version));
1980 if let Err(e) = copy_tree(&src, &dst) {
1981 eprintln!("s vendor: copy {}: {}", src.display(), e);
1982 return 1;
1983 }
1984 copied += 1;
1985 }
1986 eprintln!(
1987 "\x1b[32m✓ vendored {} package{} into {}\x1b[0m",
1988 copied,
1989 if copied == 1 { "" } else { "s" },
1990 vendor_dir.display()
1991 );
1992 0
1993}
1994
1995fn copy_tree(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
1999 std::fs::create_dir_all(dst)?;
2000 for entry in std::fs::read_dir(src)? {
2001 let entry = entry?;
2002 let from = entry.path();
2003 let to = dst.join(entry.file_name());
2004 let meta = entry.metadata()?;
2005 if meta.is_dir() {
2006 copy_tree(&from, &to)?;
2007 } else if meta.file_type().is_symlink() {
2008 #[cfg(unix)]
2009 {
2010 let target = std::fs::read_link(&from)?;
2011 std::os::unix::fs::symlink(target, &to)?;
2012 }
2013 #[cfg(not(unix))]
2014 std::fs::copy(&from, &to)?;
2015 } else {
2016 std::fs::copy(&from, &to)?;
2017 }
2018 }
2019 Ok(())
2020}
2021
2022pub fn cmd_install_global(args: &[String]) -> i32 {
2026 if args.iter().any(|a| is_help_flag(a)) || args.is_empty() {
2027 println!("usage: stryke install -g SPEC");
2028 println!();
2029 println!("SPEC is one of:");
2030 println!(" PATH local dir with stryke.toml");
2031 println!(" gh:owner/repo[@VERSION] GitHub release (prebuilt)");
2032 println!(" github.com/owner/repo[@VERSION] GitHub release (prebuilt)");
2033 println!(" https://github.com/owner/repo[@VERSION]");
2034 println!();
2035 println!("Local installs copy the source into ~/.stryke/store/<name>@<version>/.");
2036 println!("GitHub installs download the prebuilt release tarball for the host");
2037 println!("triple (override with STRYKE_TARGET=...), verify its SHA-256, and");
2038 println!("extract into the store. Launchers in ~/.stryke/bin/ point at the");
2039 println!("store entry. When the package declares [ffi], its cdylib loads");
2040 println!("lazily on first `use <namespace>`.");
2041 return if args.is_empty() { 1 } else { 0 };
2042 }
2043
2044 let spec = match InstallSpec::parse(&args[0]) {
2045 Ok(s) => s,
2046 Err(msg) => {
2047 eprintln!("s install -g: {}", msg);
2048 return 1;
2049 }
2050 };
2051
2052 let store = match Store::user_default() {
2053 Ok(s) => s,
2054 Err(e) => {
2055 eprintln!("s install -g: {}", e);
2056 return 1;
2057 }
2058 };
2059 if let Err(e) = store.ensure_layout() {
2060 eprintln!("s install -g: {}", e);
2061 return 1;
2062 }
2063
2064 let (manifest, store_pkg_dir, source) = match spec {
2065 InstallSpec::Path(p) => match install_global_from_path(&store, &p) {
2066 Ok(v) => v,
2067 Err(msg) => {
2068 eprintln!("s install -g: {}", msg);
2069 return 1;
2070 }
2071 },
2072 InstallSpec::GitHub {
2073 owner,
2074 repo,
2075 version,
2076 } => match install_global_from_github(&store, &owner, &repo, version.as_deref()) {
2077 Ok(v) => v,
2078 Err(msg) => {
2079 eprintln!("s install -g: {}", msg);
2080 return 1;
2081 }
2082 },
2083 };
2084
2085 if let Err(msg) = finalize_global_install(&store, &manifest, &store_pkg_dir, &source) {
2086 eprintln!("s install -g: {}", msg);
2087 return 1;
2088 }
2089 0
2090}
2091
2092fn finalize_global_install(
2096 store: &Store,
2097 manifest: &Manifest,
2098 store_pkg_dir: &Path,
2099 source: &str,
2100) -> Result<(), String> {
2101 for (bin_name, entry) in &manifest.bin {
2104 let target = store_pkg_dir.join(entry);
2105 if !target.is_file() {
2106 return Err(format!(
2107 "bin `{}` -> {} does not exist",
2108 bin_name,
2109 target.display()
2110 ));
2111 }
2112 let launcher = store.bin_dir().join(bin_name);
2113 write_launcher(&launcher, &target)
2114 .map_err(|e| format!("write {}: {}", launcher.display(), e))?;
2115 eprintln!(" installed {} -> {}", launcher.display(), target.display());
2116 }
2117
2118 if let Some(pkg) = manifest.package.as_ref() {
2122 let mut idx =
2123 InstalledIndex::load_or_default().map_err(|e| format!("load installed.toml: {}", e))?;
2124 if let Some(prev) = idx.find(&pkg.name) {
2128 if prev.version != pkg.version {
2129 let old_dir = store.package_dir(&pkg.name, &prev.version);
2130 eprintln!(
2131 " \x1b[33mreplacing pinned {} {} → {}\x1b[0m ({} kept on disk; run `s pkg gc -g` to free)",
2132 pkg.name,
2133 prev.version,
2134 pkg.version,
2135 old_dir.display()
2136 );
2137 }
2138 }
2139 let namespace = manifest
2140 .ffi
2141 .as_ref()
2142 .map(|f| f.namespace.clone())
2143 .unwrap_or_default();
2144 idx.upsert_with_namespace(&pkg.name, &pkg.version, source, &namespace);
2145 idx.save()
2146 .map_err(|e| format!("write installed.toml: {}", e))?;
2147 }
2148
2149 let pkg_label = manifest
2150 .package
2151 .as_ref()
2152 .map(|p| format!("{}@{}", p.name, p.version))
2153 .unwrap_or_else(|| "package".to_string());
2154 let mode_label = if manifest.ffi.is_some() {
2155 " [ffi cdylib]"
2156 } else {
2157 ""
2158 };
2159 eprintln!(
2160 "\x1b[32m✓ {} installed{} → {}\x1b[0m",
2161 pkg_label,
2162 mode_label,
2163 store_pkg_dir.display()
2164 );
2165 if !manifest.bin.is_empty() {
2166 eprintln!(" (add {} to PATH)", store.bin_dir().display());
2167 }
2168 Ok(())
2169}
2170
2171fn drop_stale_alias_entry(entry_name: &str, manifest: &Manifest) -> Result<(), String> {
2177 let Some(pkg) = manifest.package.as_ref() else {
2178 return Ok(());
2179 };
2180 if pkg.name == entry_name {
2181 return Ok(());
2182 }
2183 let mut idx =
2184 InstalledIndex::load_or_default().map_err(|e| format!("load installed.toml: {}", e))?;
2185 if idx.remove(entry_name) {
2186 idx.save()
2187 .map_err(|e| format!("write installed.toml: {}", e))?;
2188 eprintln!(
2189 " dropped stale pin `{}` (package is named `{}` upstream)",
2190 entry_name, pkg.name
2191 );
2192 }
2193 Ok(())
2194}
2195
2196enum PinnedSource {
2199 GitHub { owner: String, repo: String },
2202 Path(PathBuf),
2205 Local,
2209}
2210
2211impl PinnedSource {
2212 fn parse(source: &str) -> Option<PinnedSource> {
2213 if let Some(rest) = source.strip_prefix("github:") {
2214 let head = rest.split('@').next().unwrap_or(rest);
2215 let (owner, repo) = parse_gh_owner_repo(head).ok()?;
2216 return Some(PinnedSource::GitHub { owner, repo });
2217 }
2218 if let Some(p) = source.strip_prefix("path+file://") {
2219 return Some(PinnedSource::Path(PathBuf::from(p)));
2220 }
2221 if source.starts_with("local-install:") {
2222 return Some(PinnedSource::Local);
2223 }
2224 None
2225 }
2226}
2227
2228fn same_version(a: &str, b: &str) -> bool {
2231 a.trim_start_matches('v') == b.trim_start_matches('v')
2232}
2233
2234fn newest_mtime(root: &Path) -> Option<std::time::SystemTime> {
2238 let meta = std::fs::symlink_metadata(root).ok()?;
2239 if !meta.is_dir() {
2240 return meta.modified().ok();
2241 }
2242 let mut newest = meta.modified().ok();
2243 if let Ok(rd) = std::fs::read_dir(root) {
2244 for e in rd.flatten() {
2245 if let Some(t) = newest_mtime(&e.path()) {
2246 newest = Some(match newest {
2247 Some(n) if n >= t => n,
2248 _ => t,
2249 });
2250 }
2251 }
2252 }
2253 newest
2254}
2255
2256fn path_source_changed(store: &Store, src: &Path, manifest: &Manifest, name: &str, version: &str) -> bool {
2266 let store_dir = store.package_dir(name, version);
2267 let Some(installed_at) = newest_mtime(&store_dir) else {
2268 return true; };
2270 let mut inputs = vec![src.join(MANIFEST_FILE), src.join("lib"), src.join("bin")];
2271 if let Some(ffi) = manifest.ffi.as_ref() {
2272 if !ffi.lib_name.is_empty() {
2273 let dll = format!(
2274 "{}{}{}",
2275 std::env::consts::DLL_PREFIX,
2276 ffi.lib_name,
2277 std::env::consts::DLL_SUFFIX
2278 );
2279 inputs.push(src.join("target/release").join(dll));
2280 }
2281 }
2282 inputs
2283 .iter()
2284 .filter_map(|p| newest_mtime(p))
2285 .any(|t| t > installed_at)
2286}
2287
2288pub fn cmd_upgrade_global(args: &[String]) -> i32 {
2298 if args.iter().any(|a| is_help_flag(a)) {
2299 println!("usage: stryke upgrade -g [NAME]");
2300 println!();
2301 println!("Upgrade globally installed packages (~/.stryke/installed.toml) to their");
2302 println!("latest upstream versions. Per pinned source:");
2303 println!(" github:owner/repo@tag fetch latest release tag, reinstall if newer");
2304 println!(" path+file:///dir re-copy when the version moved, or when the");
2305 println!(" working tree changed since the last install");
2306 println!(" local-install:... pruned — legacy project-install rows; project");
2307 println!(" deps are pinned by their stryke.lock, not here");
2308 println!();
2309 println!("NAME: when given, only that package is upgraded.");
2310 return 0;
2311 }
2312 let filter = args.iter().find(|a| !a.starts_with('-')).cloned();
2313 let store = match Store::user_default() {
2314 Ok(s) => s,
2315 Err(e) => {
2316 eprintln!("s upgrade -g: {}", e);
2317 return 1;
2318 }
2319 };
2320 let idx = match InstalledIndex::load_or_default() {
2321 Ok(i) => i,
2322 Err(e) => {
2323 eprintln!("s upgrade -g: load installed.toml: {}", e);
2324 return 1;
2325 }
2326 };
2327 let entries: Vec<_> = idx
2330 .packages
2331 .iter()
2332 .filter(|p| filter.as_deref().map_or(true, |f| p.name == f))
2333 .cloned()
2334 .collect();
2335 if entries.is_empty() {
2336 match filter {
2337 Some(f) => {
2338 eprintln!("s upgrade -g: `{}` is not in installed.toml", f);
2339 return 1;
2340 }
2341 None => {
2342 eprintln!("s upgrade -g: no global packages installed");
2343 return 0;
2344 }
2345 }
2346 }
2347
2348 let (mut upgraded, mut refreshed, mut current, mut pruned, mut skipped, mut failed) =
2349 (0u32, 0u32, 0u32, 0u32, 0u32, 0u32);
2350 for entry in &entries {
2351 match PinnedSource::parse(&entry.source) {
2352 Some(PinnedSource::GitHub { owner, repo }) => {
2353 let latest = match fetch_latest_release_tag(&owner, &repo) {
2354 Ok(t) => t,
2355 Err(e) => {
2356 eprintln!(" \x1b[31m✗ {}: {}\x1b[0m", entry.name, e);
2357 failed += 1;
2358 continue;
2359 }
2360 };
2361 if same_version(&latest, &entry.version) {
2362 eprintln!(" ✓ {}@{} up to date", entry.name, entry.version);
2363 current += 1;
2364 continue;
2365 }
2366 match install_global_from_github(&store, &owner, &repo, Some(&latest)).and_then(
2367 |(m, dir, src)| {
2368 finalize_global_install(&store, &m, &dir, &src)?;
2369 drop_stale_alias_entry(&entry.name, &m)
2370 },
2371 ) {
2372 Ok(()) => upgraded += 1,
2373 Err(e) => {
2374 eprintln!(" \x1b[31m✗ {}: {}\x1b[0m", entry.name, e);
2375 failed += 1;
2376 }
2377 }
2378 }
2379 Some(PinnedSource::Path(dir)) => {
2380 let upstream = match Manifest::from_path(&dir.join(MANIFEST_FILE)) {
2381 Ok(m) => m,
2382 Err(e) => {
2383 eprintln!(
2384 " \x1b[31m✗ {}: source dir {}: {}\x1b[0m",
2385 entry.name,
2386 dir.display(),
2387 e
2388 );
2389 failed += 1;
2390 continue;
2391 }
2392 };
2393 let upstream_version = upstream
2394 .package
2395 .as_ref()
2396 .map(|p| p.version.clone())
2397 .unwrap_or_default();
2398 if let Some((owner, repo)) = upstream
2407 .package
2408 .as_ref()
2409 .and_then(|p| parse_github_repo(&p.repository))
2410 {
2411 if let Ok(latest) = fetch_latest_release_tag(&owner, &repo) {
2412 let installed_rank =
2413 VersionRank::parse(entry.version.trim_start_matches('v'));
2414 let local_rank =
2415 VersionRank::parse(upstream_version.trim_start_matches('v'));
2416 let latest_rank = VersionRank::parse(latest.trim_start_matches('v'));
2417 if latest_rank > installed_rank && latest_rank > local_rank {
2418 match install_global_from_github(&store, &owner, &repo, Some(&latest))
2419 .and_then(|(m, gdir, src)| {
2420 finalize_global_install(&store, &m, &gdir, &src)?;
2421 drop_stale_alias_entry(&entry.name, &m)
2422 }) {
2423 Ok(()) => {
2424 eprintln!(
2425 " ⬆ {}@{} -> {} from github:{}/{}",
2426 entry.name, entry.version, latest, owner, repo
2427 );
2428 upgraded += 1;
2429 }
2430 Err(e) => {
2431 eprintln!(" \x1b[31m✗ {}: {}\x1b[0m", entry.name, e);
2432 failed += 1;
2433 }
2434 }
2435 continue;
2436 }
2437 }
2438 }
2439 let bumped = !same_version(&upstream_version, &entry.version);
2449 if !bumped
2450 && !path_source_changed(&store, &dir, &upstream, &entry.name, &entry.version)
2451 {
2452 eprintln!(" ✓ {}@{} up to date", entry.name, entry.version);
2453 current += 1;
2454 continue;
2455 }
2456 match install_global_from_path(&store, &dir).and_then(|(m, sdir, src)| {
2457 finalize_global_install(&store, &m, &sdir, &src)?;
2458 drop_stale_alias_entry(&entry.name, &m)
2459 }) {
2460 Ok(()) => {
2461 if bumped {
2462 upgraded += 1;
2463 } else {
2464 eprintln!(
2465 " ↻ {}@{} refreshed (source changed, version unchanged)",
2466 entry.name, upstream_version
2467 );
2468 refreshed += 1;
2469 }
2470 }
2471 Err(e) => {
2472 eprintln!(" \x1b[31m✗ {}: {}\x1b[0m", entry.name, e);
2473 failed += 1;
2474 }
2475 }
2476 }
2477 Some(PinnedSource::Local) => {
2478 match InstalledIndex::load_or_default() {
2483 Ok(mut idx) => {
2484 if idx.remove(&entry.name) {
2485 if let Err(e) = idx.save() {
2486 eprintln!(" \x1b[31m✗ {}: write installed.toml: {}\x1b[0m", entry.name, e);
2487 failed += 1;
2488 continue;
2489 }
2490 }
2491 eprintln!(
2492 " - {}@{} legacy project-install row pruned (project deps live in stryke.lock)",
2493 entry.name, entry.version
2494 );
2495 pruned += 1;
2496 }
2497 Err(e) => {
2498 eprintln!(" \x1b[31m✗ {}: load installed.toml: {}\x1b[0m", entry.name, e);
2499 failed += 1;
2500 }
2501 }
2502 }
2503 None => {
2504 eprintln!(
2505 " - {}@{} unrecognized source `{}` — skipped",
2506 entry.name, entry.version, entry.source
2507 );
2508 skipped += 1;
2509 }
2510 }
2511 }
2512
2513 let total = upgraded + refreshed + current + skipped + failed;
2515 let mut summary = format!(
2516 "{} installed: {} upgraded, {} up to date",
2517 total, upgraded, current
2518 );
2519 if refreshed > 0 {
2520 summary.push_str(&format!(", {} refreshed", refreshed));
2521 }
2522 if pruned > 0 {
2523 summary.push_str(&format!(", {} legacy project rows pruned", pruned));
2524 }
2525 if skipped > 0 {
2526 summary.push_str(&format!(", {} skipped", skipped));
2527 }
2528 summary.push_str(&format!(", {} failed", failed));
2529 eprintln!("{}", summary);
2530 if failed > 0 {
2531 1
2532 } else {
2533 0
2534 }
2535}
2536
2537enum InstallSpec {
2539 Path(PathBuf),
2541 GitHub {
2543 owner: String,
2544 repo: String,
2545 version: Option<String>,
2547 },
2548}
2549
2550impl InstallSpec {
2551 fn parse(arg: &str) -> Result<InstallSpec, String> {
2552 let (head, version) = split_version_suffix(arg);
2553 if let Some(rest) = head.strip_prefix("gh:") {
2554 let (owner, repo) = parse_gh_owner_repo(rest)?;
2555 return Ok(InstallSpec::GitHub {
2556 owner,
2557 repo,
2558 version,
2559 });
2560 }
2561 for prefix in ["https://github.com/", "http://github.com/", "github.com/"] {
2562 if let Some(rest) = head.strip_prefix(prefix) {
2563 let (owner, repo) = parse_gh_owner_repo(rest)?;
2564 return Ok(InstallSpec::GitHub {
2565 owner,
2566 repo,
2567 version,
2568 });
2569 }
2570 }
2571 let path = PathBuf::from(head);
2572 if !path.is_dir() {
2573 return Err(format!(
2574 "`{}` is not a directory or a recognized GitHub spec",
2575 head
2576 ));
2577 }
2578 Ok(InstallSpec::Path(path))
2579 }
2580}
2581
2582fn split_version_suffix(arg: &str) -> (&str, Option<String>) {
2586 if let Some(at_pos) = arg.rfind('@') {
2587 let v = &arg[at_pos + 1..];
2588 let looks_like_version = !v.is_empty()
2589 && (v.starts_with('v') || v.chars().next().map_or(false, |c| c.is_ascii_digit()));
2590 if looks_like_version {
2591 return (&arg[..at_pos], Some(v.to_string()));
2592 }
2593 }
2594 (arg, None)
2595}
2596
2597fn parse_github_repo(url: &str) -> Option<(String, String)> {
2604 let url = url.trim();
2605 if url.is_empty() {
2606 return None;
2607 }
2608 if let Some(rest) = url.strip_prefix("git@github.com:") {
2609 return parse_gh_owner_repo(rest).ok();
2610 }
2611 for prefix in [
2612 "gh:",
2613 "https://github.com/",
2614 "http://github.com/",
2615 "git://github.com/",
2616 "ssh://git@github.com/",
2617 "github.com/",
2618 ] {
2619 if let Some(rest) = url.strip_prefix(prefix) {
2620 return parse_gh_owner_repo(rest).ok();
2621 }
2622 }
2623 None
2624}
2625
2626fn parse_gh_owner_repo(s: &str) -> Result<(String, String), String> {
2627 let cleaned = s.trim_end_matches('/').trim_end_matches(".git");
2628 let mut parts = cleaned.splitn(2, '/');
2629 let owner = parts
2630 .next()
2631 .filter(|s| !s.is_empty())
2632 .ok_or("expected owner/repo")?;
2633 let repo = parts
2634 .next()
2635 .filter(|s| !s.is_empty())
2636 .ok_or("expected owner/repo")?;
2637 Ok((owner.to_string(), repo.to_string()))
2638}
2639
2640fn install_global_from_path(
2641 store: &Store,
2642 src: &Path,
2643) -> Result<(Manifest, PathBuf, String), String> {
2644 let abs = src
2645 .canonicalize()
2646 .map_err(|e| format!("canonicalize {}: {}", src.display(), e))?;
2647 let manifest = Manifest::from_path(&abs.join(MANIFEST_FILE)).map_err(|e| e.to_string())?;
2648 let pkg = manifest
2649 .package
2650 .as_ref()
2651 .ok_or("manifest missing [package]")?;
2652
2653 let ffi_stage = ensure_ffi_cdylib(&abs, &manifest)?;
2662
2663 let dst = store
2664 .install_path_dep(&pkg.name, &pkg.version, &abs, &manifest)
2665 .map_err(|e| e.to_string())?;
2666
2667 if let Some((built_lib, lib_filename)) = ffi_stage {
2672 let dst_lib_dir = dst.join("lib");
2673 std::fs::create_dir_all(&dst_lib_dir)
2674 .map_err(|e| format!("create {}: {}", dst_lib_dir.display(), e))?;
2675 let dst_lib = dst_lib_dir.join(&lib_filename);
2676 if !dst_lib.is_file() {
2677 std::fs::copy(&built_lib, &dst_lib).map_err(|e| {
2678 format!(
2679 "stage cdylib {} -> {}: {}",
2680 built_lib.display(),
2681 dst_lib.display(),
2682 e
2683 )
2684 })?;
2685 eprintln!(" staged {} -> {}", built_lib.display(), dst_lib.display());
2686 }
2687 }
2688
2689 let source = format!("path+file://{}", abs.display());
2690 Ok((manifest, dst, source))
2691}
2692
2693pub(crate) fn ensure_ffi_cdylib(
2700 src: &Path,
2701 manifest: &Manifest,
2702) -> Result<Option<(PathBuf, String)>, String> {
2703 let Some(ffi) = manifest.ffi.as_ref() else {
2704 return Ok(None);
2705 };
2706 if ffi.lib_name.is_empty() {
2707 return Ok(None);
2708 }
2709 let lib_filename = format!(
2710 "{}{}{}",
2711 std::env::consts::DLL_PREFIX,
2712 ffi.lib_name,
2713 std::env::consts::DLL_SUFFIX
2714 );
2715
2716 let in_lib = src.join("lib").join(&lib_filename);
2717 if in_lib.is_file() {
2718 return Ok(None);
2721 }
2722
2723 let release_built = src.join("target/release").join(&lib_filename);
2724 if release_built.is_file() {
2725 return Ok(Some((release_built, lib_filename)));
2726 }
2727
2728 let cargo_toml = src.join("Cargo.toml");
2732 if !cargo_toml.is_file() {
2733 return Err(format!(
2734 "[ffi] declared but cdylib `{}` not found and no Cargo.toml at {} \
2735 to build it (looked under lib/ and target/release/)",
2736 lib_filename,
2737 src.display()
2738 ));
2739 }
2740
2741 eprintln!(" building cdylib: cargo build --release ({})", src.display());
2742 let status = std::process::Command::new("cargo")
2743 .arg("build")
2744 .arg("--release")
2745 .current_dir(src)
2746 .status()
2747 .map_err(|e| format!("spawn cargo: {}", e))?;
2748 if !status.success() {
2749 return Err(format!(
2750 "cargo build --release failed (exit {:?}) in {}",
2751 status.code(),
2752 src.display()
2753 ));
2754 }
2755
2756 if !release_built.is_file() {
2757 return Err(format!(
2758 "cargo build --release succeeded but `{}` was not produced at {} \
2759 — check `[lib].name` in Cargo.toml matches `[ffi].lib-name` in stryke.toml",
2760 lib_filename,
2761 release_built.display()
2762 ));
2763 }
2764 Ok(Some((release_built, lib_filename)))
2765}
2766
2767pub(crate) fn install_global_from_github(
2768 store: &Store,
2769 owner: &str,
2770 repo: &str,
2771 requested_version: Option<&str>,
2772) -> Result<(Manifest, PathBuf, String), String> {
2773 let tag = match requested_version {
2774 Some(v) => v.to_string(),
2775 None => fetch_latest_release_tag(owner, repo)?,
2776 };
2777 let triple = host_target_triple()?;
2778 let asset_stem = repo.to_lowercase();
2779 let asset = format!("{}-{}-{}.tar.gz", asset_stem, tag, triple);
2780 let url_tar = format!(
2781 "https://github.com/{}/{}/releases/download/{}/{}",
2782 owner, repo, tag, asset
2783 );
2784 let url_sha = format!("{}.sha256", url_tar);
2785
2786 eprintln!(" fetching {} ...", url_tar);
2787
2788 let sha_text = ureq::get(&url_sha)
2789 .call()
2790 .map_err(|e| format!("GET {}: {}", url_sha, e))?
2791 .into_string()
2792 .map_err(|e| format!("read {}: {}", url_sha, e))?;
2793 let expected_sha = sha_text
2794 .split_whitespace()
2795 .next()
2796 .ok_or("empty sha256 file")?
2797 .to_string();
2798
2799 use std::io::Read;
2800 let mut bytes = Vec::new();
2801 ureq::get(&url_tar)
2802 .call()
2803 .map_err(|e| format!("GET {}: {}", url_tar, e))?
2804 .into_reader()
2805 .read_to_end(&mut bytes)
2806 .map_err(|e| format!("read {}: {}", url_tar, e))?;
2807
2808 use sha2::Digest as _;
2809 let actual = hex::encode(sha2::Sha256::digest(&bytes));
2810 if !expected_sha.eq_ignore_ascii_case(&actual) {
2811 return Err(format!(
2812 "sha256 mismatch on {}: expected {} got {}",
2813 asset, expected_sha, actual
2814 ));
2815 }
2816
2817 let stage_dir = store
2820 .cache_dir()
2821 .join(format!("install-stage-{}-{}-{}", asset_stem, tag, triple));
2822 if stage_dir.exists() {
2823 std::fs::remove_dir_all(&stage_dir)
2824 .map_err(|e| format!("clear {}: {}", stage_dir.display(), e))?;
2825 }
2826 std::fs::create_dir_all(&stage_dir)
2827 .map_err(|e| format!("mkdir {}: {}", stage_dir.display(), e))?;
2828 let decoder = flate2::read::GzDecoder::new(std::io::Cursor::new(&bytes));
2829 tar::Archive::new(decoder)
2830 .unpack(&stage_dir)
2831 .map_err(|e| format!("extract {}: {}", asset, e))?;
2832
2833 let manifest_dir = locate_manifest_dir(&stage_dir)?;
2834 let manifest = Manifest::from_path(&manifest_dir.join(MANIFEST_FILE))
2835 .map_err(|e| format!("installed stryke.toml: {}", e))?;
2836 let pkg = manifest
2837 .package
2838 .as_ref()
2839 .ok_or("installed manifest missing [package]")?;
2840
2841 let dst = store.package_dir(&pkg.name, &pkg.version);
2842 if dst.exists() {
2843 std::fs::remove_dir_all(&dst)
2844 .map_err(|e| format!("clear existing store entry {}: {}", dst.display(), e))?;
2845 }
2846 if let Some(parent) = dst.parent() {
2847 std::fs::create_dir_all(parent).ok();
2848 }
2849 std::fs::rename(&manifest_dir, &dst).map_err(|e| {
2850 format!(
2851 "rename {} -> {}: {}",
2852 manifest_dir.display(),
2853 dst.display(),
2854 e
2855 )
2856 })?;
2857 let _ = std::fs::remove_dir_all(&stage_dir);
2858
2859 let source = format!("github:{}/{}@{}", owner, repo, tag);
2860 Ok((manifest, dst, source))
2861}
2862
2863fn fetch_latest_release_tag(owner: &str, repo: &str) -> Result<String, String> {
2866 let url = format!(
2867 "https://api.github.com/repos/{}/{}/releases/latest",
2868 owner, repo
2869 );
2870 let resp = ureq::get(&url)
2871 .set("User-Agent", "stryke-pkg")
2872 .set("Accept", "application/vnd.github+json")
2873 .call()
2874 .map_err(|e| format!("GET {}: {}", url, e))?;
2875 let v: serde_json::Value = resp
2876 .into_json()
2877 .map_err(|e| format!("parse {}: {}", url, e))?;
2878 v.get("tag_name")
2879 .and_then(|t| t.as_str())
2880 .map(String::from)
2881 .ok_or_else(|| format!("no tag_name in {} response", url))
2882}
2883
2884fn host_target_triple() -> Result<String, String> {
2889 if let Ok(t) = std::env::var("STRYKE_TARGET") {
2890 if !t.is_empty() {
2891 return Ok(t);
2892 }
2893 }
2894 let arch = std::env::consts::ARCH;
2895 let os = std::env::consts::OS;
2896 let triple = match (arch, os) {
2897 ("aarch64", "macos") => "aarch64-apple-darwin",
2898 ("x86_64", "macos") => "x86_64-apple-darwin",
2899 ("x86_64", "linux") => "x86_64-unknown-linux-gnu",
2900 ("aarch64", "linux") => "aarch64-unknown-linux-gnu",
2901 _ => {
2902 return Err(format!(
2903 "no prebuilt asset triple for host {}-{}; set STRYKE_TARGET=... to override",
2904 arch, os
2905 ));
2906 }
2907 };
2908 Ok(triple.to_string())
2909}
2910
2911fn locate_manifest_dir(root: &Path) -> Result<PathBuf, String> {
2915 if root.join(MANIFEST_FILE).is_file() {
2916 return Ok(root.to_path_buf());
2917 }
2918 let mut subdirs: Vec<PathBuf> = Vec::new();
2919 for entry in std::fs::read_dir(root).map_err(|e| format!("read {}: {}", root.display(), e))? {
2920 let entry = entry.map_err(|e| e.to_string())?;
2921 if entry.path().is_dir() {
2922 subdirs.push(entry.path());
2923 }
2924 }
2925 if subdirs.len() != 1 {
2926 return Err(format!(
2927 "expected stryke.toml at tarball root or a single top-level dir in {}, found {} dirs",
2928 root.display(),
2929 subdirs.len()
2930 ));
2931 }
2932 let s = subdirs.remove(0);
2933 if !s.join(MANIFEST_FILE).is_file() {
2934 return Err(format!("no stryke.toml in {}", s.display()));
2935 }
2936 Ok(s)
2937}
2938
2939fn write_launcher(
2944 launcher_path: &std::path::Path,
2945 target: &std::path::Path,
2946) -> std::io::Result<()> {
2947 if launcher_path.exists() {
2948 std::fs::remove_file(launcher_path)?;
2949 }
2950 let body = format!(
2951 "#!/bin/sh\nexec stryke {:?} \"$@\"\n",
2952 target.display().to_string()
2953 );
2954 std::fs::write(launcher_path, body)?;
2955 #[cfg(unix)]
2956 {
2957 use std::os::unix::fs::PermissionsExt;
2958 let mut perms = std::fs::metadata(launcher_path)?.permissions();
2959 perms.set_mode(0o755);
2960 std::fs::set_permissions(launcher_path, perms)?;
2961 }
2962 Ok(())
2963}
2964
2965pub fn cmd_uninstall_global(args: &[String]) -> i32 {
2971 if args.iter().any(|a| is_help_flag(a)) || args.is_empty() {
2972 println!("usage: stryke uninstall -g NAME");
2973 println!();
2974 println!("Remove a package installed by `stryke install -g`. NAME is the");
2975 println!("package name (the `[package].name` field of its stryke.toml, or");
2976 println!("equivalently a launcher in ~/.stryke/bin/). Drops the entry from");
2977 println!("~/.stryke/installed.toml and removes any associated launchers.");
2978 return if args.is_empty() { 1 } else { 0 };
2979 }
2980 let store = match Store::user_default() {
2981 Ok(s) => s,
2982 Err(e) => {
2983 eprintln!("s uninstall -g: {}", e);
2984 return 1;
2985 }
2986 };
2987 let name = &args[0];
2988
2989 let mut idx = match InstalledIndex::load_or_default() {
2991 Ok(i) => i,
2992 Err(e) => {
2993 eprintln!("s uninstall -g: load installed.toml: {}", e);
2994 return 1;
2995 }
2996 };
2997 let index_entry = idx.find(name).cloned();
2998 let mut removed_anything = false;
2999
3000 if let Some(entry) = index_entry {
3001 let store_pkg = store.package_dir(&entry.name, &entry.version);
3002 let manifest_path = store_pkg.join(MANIFEST_FILE);
3003 if manifest_path.is_file() {
3004 if let Ok(m) = Manifest::from_path(&manifest_path) {
3005 for bin_name in m.bin.keys() {
3006 let launcher = store.bin_dir().join(bin_name);
3007 if launcher.exists() {
3008 match std::fs::remove_file(&launcher) {
3009 Ok(_) => {
3010 eprintln!(" removed launcher {}", launcher.display());
3018 }
3019 Err(e) => {
3020 eprintln!("s uninstall -g: remove {}: {}", launcher.display(), e);
3021 return 1;
3022 }
3023 }
3024 }
3025 }
3026 }
3027 }
3028 idx.remove(name);
3029 if let Err(e) = idx.save() {
3030 eprintln!("s uninstall -g: write installed.toml: {}", e);
3031 return 1;
3032 }
3033 eprintln!(
3034 " unpinned {} (store entry kept at {})",
3035 name,
3036 store_pkg.display()
3037 );
3038 removed_anything = true;
3039 } else {
3040 let target = store.bin_dir().join(name);
3043 if target.exists() {
3044 if let Err(e) = std::fs::remove_file(&target) {
3045 eprintln!("s uninstall -g: remove {}: {}", target.display(), e);
3046 return 1;
3047 }
3048 eprintln!(" removed launcher {}", target.display());
3049 removed_anything = true;
3050 }
3051 }
3052
3053 if !removed_anything {
3054 eprintln!("s uninstall -g: {} not installed", name);
3055 return 1;
3056 }
3057 0
3058}
3059
3060pub fn cmd_use_global(args: &[String]) -> i32 {
3068 if args.iter().any(|a| is_help_flag(a)) || args.is_empty() {
3069 println!("usage: stryke use -g NAME@VERSION");
3070 println!();
3071 println!("Switch the global pin in ~/.stryke/installed.toml to a different");
3072 println!("version of an already-installed package. The version must already");
3073 println!("exist as ~/.stryke/store/<name>@<version>/ — this command does NOT");
3074 println!("download anything; use `s pkg install -g <spec>@<version>` to fetch.");
3075 println!();
3076 println!("Standalone scripts that `use <Namespace>` will resolve to this");
3077 println!("version. Inside a project the stryke.lock still wins.");
3078 return if args.is_empty() { 1 } else { 0 };
3079 }
3080 let spec = &args[0];
3081 let (name, version) = match spec.split_once('@') {
3082 Some((n, v)) if !n.is_empty() && !v.is_empty() => (n.to_string(), v.to_string()),
3083 _ => {
3084 eprintln!("s use -g: spec must be NAME@VERSION, got `{}`", spec);
3085 return 1;
3086 }
3087 };
3088
3089 let store = match Store::user_default() {
3090 Ok(s) => s,
3091 Err(e) => {
3092 eprintln!("s use -g: {}", e);
3093 return 1;
3094 }
3095 };
3096 let store_pkg = store.package_dir(&name, &version);
3097 if !store_pkg.is_dir() {
3098 eprintln!(
3099 "s use -g: {}@{} is not installed (no {} directory). \
3100 Run `s pkg install -g <spec>@{}` to fetch it first.",
3101 name,
3102 version,
3103 store_pkg.display(),
3104 version
3105 );
3106 return 1;
3107 }
3108
3109 let mut idx = match InstalledIndex::load_from(&store) {
3110 Ok(i) => i,
3111 Err(e) => {
3112 eprintln!("s use -g: load installed.toml: {}", e);
3113 return 1;
3114 }
3115 };
3116 let previous = idx.find(&name).map(|p| p.version.clone());
3117 let source = idx
3118 .find(&name)
3119 .map(|p| p.source.clone())
3120 .unwrap_or_else(|| format!("local-pin:store/{}@{}", name, version));
3121 let namespace = idx
3122 .find(&name)
3123 .map(|p| p.namespace.clone())
3124 .unwrap_or_default();
3125 idx.upsert_with_namespace(&name, &version, &source, &namespace);
3126 if let Err(e) = idx.save_to(&store) {
3127 eprintln!("s use -g: write installed.toml: {}", e);
3128 return 1;
3129 }
3130
3131 match previous {
3132 Some(prev) if prev != version => {
3133 eprintln!(
3134 "\x1b[32m✓ {} pin {} → {}\x1b[0m ({} still on disk; run `s pkg gc -g` to free it)",
3135 name,
3136 prev,
3137 version,
3138 store.package_dir(&name, &prev).display()
3139 );
3140 }
3141 Some(_) => {
3142 eprintln!("\x1b[32m✓ {} pin already at {}\x1b[0m", name, version);
3143 }
3144 None => {
3145 eprintln!(
3146 "\x1b[32m✓ pinned {}@{}\x1b[0m (was not in installed.toml)",
3147 name, version
3148 );
3149 }
3150 }
3151 0
3152}
3153
3154pub fn cmd_gc_global(args: &[String]) -> i32 {
3164 if args.iter().any(|a| is_help_flag(a)) {
3165 println!("usage: stryke gc -g [--dry-run]");
3166 println!();
3167 println!("Remove every ~/.stryke/store/<name>@<version>/ directory not pinned");
3168 println!("by ~/.stryke/installed.toml. Project lockfiles are not consulted —");
3169 println!("a store entry deleted here will be re-fetched on the next `s install`");
3170 println!("inside a project that pinned it.");
3171 println!();
3172 println!("Flags:");
3173 println!(" --dry-run list what would be removed without touching disk");
3174 return 0;
3175 }
3176 let dry_run = args.iter().any(|a| a == "--dry-run" || a == "-n");
3177
3178 let store = match Store::user_default() {
3179 Ok(s) => s,
3180 Err(e) => {
3181 eprintln!("s gc -g: {}", e);
3182 return 1;
3183 }
3184 };
3185 let idx = match InstalledIndex::load_from(&store) {
3186 Ok(i) => i,
3187 Err(e) => {
3188 eprintln!("s gc -g: load installed.toml: {}", e);
3189 return 1;
3190 }
3191 };
3192
3193 let orphans = scan_orphan_store_dirs(&store, &idx);
3194 if orphans.is_empty() {
3195 eprintln!(" no orphan store entries");
3196 return 0;
3197 }
3198
3199 let mut total_bytes: u64 = 0;
3200 let mut total_count: usize = 0;
3201 for (name, version, path) in &orphans {
3202 let bytes = dir_size_recursive(path);
3203 total_bytes += bytes;
3204 total_count += 1;
3205 if dry_run {
3206 eprintln!(
3207 " would remove {}@{} ({} KB)",
3208 name,
3209 version,
3210 (bytes + 512) / 1024
3211 );
3212 } else {
3213 match std::fs::remove_dir_all(path) {
3214 Ok(_) => eprintln!(
3215 " removed {}@{} ({} KB)",
3216 name,
3217 version,
3218 (bytes + 512) / 1024
3219 ),
3220 Err(e) => {
3221 eprintln!("s gc -g: remove {}: {}", path.display(), e);
3222 return 1;
3223 }
3224 }
3225 }
3226 }
3227 let verb = if dry_run { "would free" } else { "freed" };
3228 eprintln!(
3229 "\x1b[32m✓ {} {} orphan{} ({} KB total)\x1b[0m",
3230 verb,
3231 total_count,
3232 if total_count == 1 { "" } else { "s" },
3233 (total_bytes + 512) / 1024
3234 );
3235 0
3236}
3237
3238fn dir_size_recursive(path: &Path) -> u64 {
3242 let mut total: u64 = 0;
3243 let entries = match std::fs::read_dir(path) {
3244 Ok(e) => e,
3245 Err(_) => return 0,
3246 };
3247 for entry in entries.flatten() {
3248 let Ok(meta) = entry.metadata() else {
3249 continue;
3250 };
3251 if meta.is_dir() {
3252 total += dir_size_recursive(&entry.path());
3253 } else if meta.is_file() {
3254 total += meta.len();
3255 }
3256 }
3257 total
3258}
3259
3260pub fn cmd_list_global(args: &[String]) -> i32 {
3262 if args.iter().any(|a| is_help_flag(a)) {
3263 println!("usage: stryke list -g");
3264 println!();
3265 println!("Three sections of global state, all under ~/.stryke/:");
3266 println!(" packages — entries in installed.toml (the pin source-of-truth)");
3267 println!(" launchers — files in bin/ (created by install -g on packages with [bin])");
3268 println!(" orphans — store/<name>@<ver>/ dirs not pinned by installed.toml");
3269 println!();
3270 println!("Run `s pkg gc -g` to remove orphans.");
3271 return 0;
3272 }
3273 let store = match Store::user_default() {
3274 Ok(s) => s,
3275 Err(e) => {
3276 eprintln!("s list -g: {}", e);
3277 return 1;
3278 }
3279 };
3280 let idx = match InstalledIndex::load_from(&store) {
3281 Ok(i) => i,
3282 Err(e) => {
3283 eprintln!("s list -g: load installed.toml: {}", e);
3284 return 1;
3285 }
3286 };
3287
3288 println!("packages:");
3290 if idx.packages.is_empty() {
3291 println!(" (none)");
3292 } else {
3293 for pkg in &idx.packages {
3294 println!(" {}@{} {}", pkg.name, pkg.version, pkg.source);
3295 }
3296 }
3297
3298 println!("launchers:");
3300 let bin_dir = store.bin_dir();
3301 let mut launcher_names: Vec<String> = Vec::new();
3302 if bin_dir.is_dir() {
3303 if let Ok(entries) = std::fs::read_dir(&bin_dir) {
3304 for entry in entries.flatten() {
3305 if let Some(n) = entry.file_name().to_str() {
3306 launcher_names.push(n.to_string());
3307 }
3308 }
3309 }
3310 }
3311 launcher_names.sort();
3312 if launcher_names.is_empty() {
3313 println!(" (none)");
3314 } else {
3315 for n in &launcher_names {
3316 println!(" {}", n);
3317 }
3318 }
3319
3320 println!("orphans:");
3322 let orphans = scan_orphan_store_dirs(&store, &idx);
3323 if orphans.is_empty() {
3324 println!(" (none)");
3325 } else {
3326 for (name, version, _path) in &orphans {
3327 println!(" {}@{}", name, version);
3328 }
3329 }
3330 0
3331}
3332
3333fn scan_orphan_store_dirs(store: &Store, idx: &InstalledIndex) -> Vec<(String, String, PathBuf)> {
3338 let mut out = Vec::new();
3339 let store_dir = store.store_dir();
3340 let entries = match std::fs::read_dir(&store_dir) {
3341 Ok(e) => e,
3342 Err(_) => return out,
3343 };
3344 for e in entries.flatten() {
3345 let name = match e.file_name().to_str().map(|s| s.to_string()) {
3346 Some(n) => n,
3347 None => continue,
3348 };
3349 let (pkg_name, version) = match name.split_once('@') {
3350 Some((n, v)) => (n.to_string(), v.to_string()),
3351 None => continue, };
3353 let pinned = idx.find(&pkg_name).map(|p| &p.version);
3354 let is_pinned = pinned.map(|v| v == &version).unwrap_or(false);
3355 if !is_pinned {
3356 out.push((pkg_name, version, e.path()));
3357 }
3358 }
3359 out.sort_by(|a, b| (a.0.as_str(), a.1.as_str()).cmp(&(b.0.as_str(), b.1.as_str())));
3360 out
3361}
3362
3363pub fn cmd_search(args: &[String]) -> i32 {
3367 if args.iter().any(|a| is_help_flag(a)) {
3368 println!("usage: stryke search NAME");
3369 println!();
3370 println!("Query the stryke registry for packages matching NAME. The registry");
3371 println!("endpoint is not deployed yet — this command emits a clear diagnostic");
3372 println!("rather than silent failure.");
3373 return 0;
3374 }
3375 if args.is_empty() {
3376 eprintln!("usage: s search NAME");
3377 return 1;
3378 }
3379 eprintln!(
3380 "s search: registry endpoint not deployed yet (RFC §\"Registry Protocol\"). \
3381 Query was `{}`.",
3382 args[0]
3383 );
3384 1
3385}
3386
3387pub fn cmd_publish(args: &[String]) -> i32 {
3391 if args.iter().any(|a| is_help_flag(a)) {
3392 println!("usage: stryke publish [--registry=URL] [--dry-run]");
3393 println!();
3394 println!("Package the project as a tarball and push to the stryke registry. The");
3395 println!("registry endpoint is not deployed yet — this command currently performs");
3396 println!("the local pack step (under --dry-run) and stops.");
3397 return 0;
3398 }
3399 let dry_run = args.iter().any(|a| a == "--dry-run");
3400 if !dry_run {
3401 eprintln!(
3402 "s publish: registry endpoint not deployed yet (RFC §\"Registry Protocol\"). \
3403 Pass --dry-run to exercise the local pack step."
3404 );
3405 return 1;
3406 }
3407 let cwd = match std::env::current_dir() {
3409 Ok(c) => c,
3410 Err(e) => {
3411 eprintln!("s publish: cwd: {}", e);
3412 return 1;
3413 }
3414 };
3415 let root = match find_project_root(&cwd) {
3416 Some(r) => r,
3417 None => {
3418 eprintln!("s publish: no stryke.toml found");
3419 return 1;
3420 }
3421 };
3422 let manifest = match Manifest::from_path(&root.join(MANIFEST_FILE)) {
3423 Ok(m) => m,
3424 Err(e) => {
3425 eprintln!("s publish: {}", e);
3426 return 1;
3427 }
3428 };
3429 if let Err(e) = manifest.validate() {
3430 eprintln!("s publish: {}", e);
3431 return 1;
3432 }
3433 let pkg = match manifest.package.as_ref() {
3434 Some(p) => p,
3435 None => {
3436 eprintln!("s publish: workspace roots can't be published — pick a member");
3437 return 1;
3438 }
3439 };
3440 let integrity = match super::lockfile::integrity_for_directory(&root) {
3441 Ok(s) => s,
3442 Err(e) => {
3443 eprintln!("s publish: hash {}: {}", root.display(), e);
3444 return 1;
3445 }
3446 };
3447 eprintln!(" would publish {} v{}", pkg.name, pkg.version);
3448 eprintln!(" source dir: {}", root.display());
3449 eprintln!(" integrity: {}", integrity);
3450 eprintln!(" (dry run — no upload performed)");
3451 0
3452}
3453
3454pub fn cmd_yank(args: &[String]) -> i32 {
3457 if args.iter().any(|a| is_help_flag(a)) {
3458 println!("usage: stryke yank VERSION");
3459 println!();
3460 println!("Mark a published version as do-not-resolve. Registry endpoint not");
3461 println!("deployed yet — this command emits a clear diagnostic rather than");
3462 println!("silent failure. Yanked versions are never deleted (immutable registry).");
3463 return 0;
3464 }
3465 if args.is_empty() {
3466 eprintln!("usage: s yank VERSION");
3467 return 1;
3468 }
3469 eprintln!(
3470 "s yank: registry endpoint not deployed yet (RFC §\"Registry Protocol\"). \
3471 Version was `{}`.",
3472 args[0]
3473 );
3474 1
3475}
3476
3477pub fn dispatch(args: &[String]) -> i32 {
3481 let want_help = args.first().map(|a| is_help_flag(a)).unwrap_or(false);
3482 if args.is_empty() || want_help {
3483 println!("usage: stryke pkg <subcommand> [args]");
3484 println!();
3485 println!("Package-manager subcommand dispatcher. The same handlers are also");
3486 println!("reachable as top-level commands (e.g. `stryke install` ≡ `stryke pkg install`).");
3487 println!();
3488 println!("Subcommands:");
3489 println!(" init [NAME] scaffold project in cwd");
3490 println!(" new NAME scaffold project at ./NAME/");
3491 println!(" install [--offline] resolve deps + write stryke.lock (alias: i)");
3492 println!(" install -g SPEC install a package globally (PATH | gh:owner/repo | github.com/...)");
3493 println!(" uninstall -g NAME drop a global pin + its launchers (alias: un)");
3494 println!(" use -g NAME@VERSION switch which installed version a standalone `use` resolves to");
3495 println!(
3496 " list -g list global packages, launchers, and orphans (alias: ls)"
3497 );
3498 println!(" gc -g [--dry-run] delete ~/.stryke/store/ entries no longer pinned");
3499 println!(" add NAME[@VER] [...] add a dep to stryke.toml");
3500 println!(" remove NAME drop a dep from stryke.toml");
3501 println!(
3502 " update [NAME] re-resolve within manifest constraints, rewrite stryke.lock (alias: up)"
3503 );
3504 println!(" upgrade [NAME] bump stryke.toml pins to latest upstream, then re-resolve");
3505 println!(" upgrade -g [NAME] upgrade global packages to latest upstream versions");
3506 println!(" outdated report deps drifted from their lock pin");
3507 println!(" audit check lockfile against advisory feed");
3508 println!(" tree print resolved dep graph");
3509 println!(" info NAME show lockfile entry for a dep");
3510 println!(" vendor snapshot store deps to ./vendor/");
3511 println!(" clean [--all] wipe target/ (and optionally global caches)");
3512 println!(" search NAME registry query (registry not deployed)");
3513 println!(
3514 " publish [--dry-run] publish to registry (registry not deployed) (alias: pub)"
3515 );
3516 println!(" yank VERSION yank a version (registry not deployed)");
3517 println!(" run SCRIPT [ARGS...] run a [scripts] entry");
3518 println!();
3519 println!("Run `stryke <subcommand> -h` for per-subcommand flags.");
3520 return if args.is_empty() { 1 } else { 0 };
3521 }
3522 match args[0].as_str() {
3523 "init" => cmd_init(args.get(1).map(|s| s.as_str())),
3524 "new" => match args.get(1) {
3525 Some(name) => cmd_new(name),
3526 None => {
3527 eprintln!("usage: s pkg new NAME");
3528 1
3529 }
3530 },
3531 "add" => cmd_add(&args[1..]),
3532 "remove" => cmd_remove(&args[1..]),
3533 "install" | "i" => {
3537 if args.iter().skip(1).any(|a| a == "-g" || a == "--global") {
3539 let filtered: Vec<String> = args[1..]
3540 .iter()
3541 .filter(|a| !matches!(a.as_str(), "-g" | "--global"))
3542 .cloned()
3543 .collect();
3544 cmd_install_global(&filtered)
3545 } else {
3546 cmd_install(&args[1..])
3547 }
3548 }
3549 "uninstall" | "un" => {
3550 if args.iter().skip(1).any(|a| a == "-g" || a == "--global") {
3551 let filtered: Vec<String> = args[1..]
3552 .iter()
3553 .filter(|a| !matches!(a.as_str(), "-g" | "--global"))
3554 .cloned()
3555 .collect();
3556 cmd_uninstall_global(&filtered)
3557 } else {
3558 eprintln!("s uninstall: pass -g for global tools (no per-project uninstall yet)");
3559 1
3560 }
3561 }
3562 "list" | "ls" => {
3563 if args.iter().skip(1).any(|a| a == "-g" || a == "--global") {
3564 let filtered: Vec<String> = args[1..]
3565 .iter()
3566 .filter(|a| !matches!(a.as_str(), "-g" | "--global"))
3567 .cloned()
3568 .collect();
3569 cmd_list_global(&filtered)
3570 } else {
3571 eprintln!("s list: pass -g to list global tools");
3572 1
3573 }
3574 }
3575 "use" => {
3576 if args.iter().skip(1).any(|a| a == "-g" || a == "--global") {
3577 let filtered: Vec<String> = args[1..]
3578 .iter()
3579 .filter(|a| !matches!(a.as_str(), "-g" | "--global"))
3580 .cloned()
3581 .collect();
3582 cmd_use_global(&filtered)
3583 } else {
3584 eprintln!("s use: pass -g to switch a global pin (no per-project `use` yet)");
3585 1
3586 }
3587 }
3588 "gc" => {
3589 if args.iter().skip(1).any(|a| a == "-g" || a == "--global") {
3590 let filtered: Vec<String> = args[1..]
3591 .iter()
3592 .filter(|a| !matches!(a.as_str(), "-g" | "--global"))
3593 .cloned()
3594 .collect();
3595 cmd_gc_global(&filtered)
3596 } else {
3597 eprintln!("s gc: pass -g to garbage-collect orphan global store entries");
3598 1
3599 }
3600 }
3601 "tree" => cmd_tree(&args[1..]),
3602 "info" => cmd_info(&args[1..]),
3603 "update" | "up" | "upgrade" => {
3604 if args.iter().skip(1).any(|a| a == "-g" || a == "--global") {
3605 let filtered: Vec<String> = args[1..]
3606 .iter()
3607 .filter(|a| !matches!(a.as_str(), "-g" | "--global"))
3608 .cloned()
3609 .collect();
3610 cmd_upgrade_global(&filtered)
3611 } else if args[0] == "upgrade" {
3612 cmd_upgrade_project(&args[1..])
3613 } else {
3614 cmd_update(&args[1..])
3615 }
3616 }
3617 "outdated" => cmd_outdated(&args[1..]),
3618 "audit" => cmd_audit(&args[1..]),
3619 "vendor" => cmd_vendor(&args[1..]),
3620 "clean" => cmd_clean(&args[1..]),
3621 "search" => cmd_search(&args[1..]),
3622 "publish" | "pub" => cmd_publish(&args[1..]),
3623 "yank" => cmd_yank(&args[1..]),
3624 "run" => cmd_run_script(&args[1..]),
3625 other => {
3626 eprintln!("s pkg: unknown subcommand `{}`", other);
3627 1
3628 }
3629 }
3630}
3631
3632#[cfg(test)]
3633mod tests {
3634 use super::*;
3635 use std::sync::Mutex;
3636
3637 static STRYKE_HOME_MUTEX: Mutex<()> = Mutex::new(());
3642
3643 fn tempdir(tag: &str) -> PathBuf {
3644 let pid = std::process::id();
3645 let nanos = std::time::SystemTime::now()
3646 .duration_since(std::time::UNIX_EPOCH)
3647 .unwrap()
3648 .subsec_nanos();
3649 let p = std::env::temp_dir().join(format!("stryke-cmd-{}-{}-{}", tag, pid, nanos));
3650 let _ = std::fs::remove_dir_all(&p);
3651 std::fs::create_dir_all(&p).unwrap();
3652 p
3653 }
3654
3655 #[test]
3656 fn pinned_source_parse_covers_all_install_writers() {
3657 match PinnedSource::parse("github:MenkeTechnologies/stryke-gui@v0.3.0") {
3660 Some(PinnedSource::GitHub { owner, repo }) => {
3661 assert_eq!(owner, "MenkeTechnologies");
3662 assert_eq!(repo, "stryke-gui");
3663 }
3664 other => panic!("expected GitHub source, got {:?}", other.is_some()),
3665 }
3666 match PinnedSource::parse("path+file:///tmp/mytool") {
3667 Some(PinnedSource::Path(p)) => assert_eq!(p, PathBuf::from("/tmp/mytool")),
3668 other => panic!("expected Path source, got {:?}", other.is_some()),
3669 }
3670 assert!(matches!(
3671 PinnedSource::parse("local-install:foo@1.0.0"),
3672 Some(PinnedSource::Local)
3673 ));
3674 assert!(PinnedSource::parse("registry:foo@1.0.0").is_none());
3675 assert!(PinnedSource::parse("").is_none());
3676 }
3677
3678 #[test]
3679 fn bump_dep_pin_no_network_paths() {
3680 let mut bare = DepSpec::Version("1.0".into());
3682 assert_eq!(bump_dep_pin("http", &mut bare), Ok(false));
3683
3684 let mut path_dep = DepSpec::Detailed(DetailedDep {
3685 path: Some("../mylib".into()),
3686 ..Default::default()
3687 });
3688 assert_eq!(bump_dep_pin("mylib", &mut path_dep), Ok(false));
3689
3690 let mut floating = DepSpec::Detailed(DetailedDep {
3692 github: Some("owner/repo".into()),
3693 ..Default::default()
3694 });
3695 assert_eq!(bump_dep_pin("float", &mut floating), Ok(false));
3696
3697 let mut gitlab = DepSpec::Detailed(DetailedDep {
3699 git: Some("https://gitlab.com/owner/repo.git".into()),
3700 tag: Some("v1.0".into()),
3701 ..Default::default()
3702 });
3703 assert_eq!(bump_dep_pin("gl", &mut gitlab), Ok(false));
3704 }
3705
3706 #[test]
3707 fn same_version_normalizes_v_prefix() {
3708 assert!(same_version("v0.2.0", "0.2.0"));
3709 assert!(same_version("0.2.0", "v0.2.0"));
3710 assert!(same_version("v1.0", "v1.0"));
3711 assert!(!same_version("v0.2.0", "0.2.1"));
3712 }
3713
3714 #[test]
3715 fn parse_github_repo_recognizes_url_forms() {
3716 let want = Some(("MenkeTechnologies".to_string(), "stryke-office".to_string()));
3717 assert_eq!(
3718 parse_github_repo("https://github.com/MenkeTechnologies/stryke-office"),
3719 want
3720 );
3721 assert_eq!(
3722 parse_github_repo("https://github.com/MenkeTechnologies/stryke-office.git"),
3723 want
3724 );
3725 assert_eq!(
3726 parse_github_repo("http://github.com/MenkeTechnologies/stryke-office/"),
3727 want
3728 );
3729 assert_eq!(
3730 parse_github_repo("github.com/MenkeTechnologies/stryke-office"),
3731 want
3732 );
3733 assert_eq!(
3734 parse_github_repo("gh:MenkeTechnologies/stryke-office"),
3735 want
3736 );
3737 assert_eq!(
3738 parse_github_repo("git@github.com:MenkeTechnologies/stryke-office.git"),
3739 want
3740 );
3741 assert_eq!(parse_github_repo(""), None);
3743 assert_eq!(
3744 parse_github_repo("https://gitlab.com/owner/repo"),
3745 None
3746 );
3747 assert_eq!(parse_github_repo("not a url"), None);
3748 }
3749
3750 #[test]
3751 fn upgrade_global_unknown_name_errors() {
3752 let _g = STRYKE_HOME_MUTEX.lock().unwrap();
3753 let home = tempdir("upgrade-unknown");
3754 std::env::set_var("STRYKE_HOME", &home);
3755 assert_eq!(cmd_upgrade_global(&["nosuchpkg".to_string()]), 1);
3757 assert_eq!(cmd_upgrade_global(&[]), 0);
3759 std::env::remove_var("STRYKE_HOME");
3760 }
3761
3762 #[test]
3763 fn upgrade_global_prunes_legacy_local_install_rows() {
3764 let _g = STRYKE_HOME_MUTEX.lock().unwrap();
3765 let home = tempdir("upgrade-prune");
3766 std::env::set_var("STRYKE_HOME", &home);
3767 let store = Store::at(&home);
3768 store.ensure_layout().unwrap();
3769 let mut idx = InstalledIndex::new();
3770 idx.upsert_with_namespace("stryke-arrow", "0.2.1", "local-install:stryke-arrow@0.2.1", "Arrow");
3771 idx.save().unwrap();
3772
3773 assert_eq!(cmd_upgrade_global(&[]), 0);
3775 let idx = InstalledIndex::load_or_default().unwrap();
3776 assert!(
3777 idx.find("stryke-arrow").is_none(),
3778 "legacy local-install row must be pruned from installed.toml"
3779 );
3780 std::env::remove_var("STRYKE_HOME");
3781 }
3782
3783 #[test]
3784 fn try_load_ffi_for_noop_without_section() {
3785 let d = tempdir("ffi-noop");
3786 std::fs::write(
3787 d.join(MANIFEST_FILE),
3788 "[package]\nname=\"plain\"\nversion=\"0.1.0\"\n",
3789 )
3790 .unwrap();
3791 try_load_ffi_for(&d).unwrap();
3793 }
3794
3795 #[test]
3796 fn try_load_ffi_for_missing_manifest_is_ok() {
3797 let d = tempdir("ffi-no-manifest");
3800 try_load_ffi_for(&d).unwrap();
3801 }
3802
3803 #[test]
3804 fn try_load_ffi_for_errors_when_lib_missing() {
3805 let d = tempdir("ffi-missing-lib");
3806 std::fs::write(
3807 d.join(MANIFEST_FILE),
3808 "[package]\nname=\"gui\"\nversion=\"0.1.0\"\n\n\
3809 [ffi]\nlib-name=\"stryke_gui\"\nnamespace=\"GUI\"\nexports=[\"gui__mouse_pos\"]\n",
3810 )
3811 .unwrap();
3812 std::fs::create_dir_all(d.join("lib")).unwrap();
3813 let err = try_load_ffi_for(&d).unwrap_err();
3814 let msg = format!("{}", err);
3817 let expected_filename = format!(
3818 "{}{}{}",
3819 std::env::consts::DLL_PREFIX,
3820 "stryke_gui",
3821 std::env::consts::DLL_SUFFIX
3822 );
3823 assert!(
3824 msg.contains(&expected_filename),
3825 "expected {} in: {}",
3826 expected_filename,
3827 msg
3828 );
3829 assert!(msg.contains("not found"), "got: {}", msg);
3830 assert!(msg.contains("install"), "got: {}", msg);
3831 }
3832
3833 #[test]
3834 fn scan_orphan_store_dirs_flags_unpinned() {
3835 let root = tempdir("scan-orphans");
3836 let store = Store::at(&root);
3837 store.ensure_layout().unwrap();
3838 for v in ["0.1.0", "0.2.0", "0.3.0"] {
3839 std::fs::create_dir_all(store.package_dir("gui", v)).unwrap();
3840 }
3841 std::fs::create_dir_all(store.package_dir("aws", "0.1.0")).unwrap();
3842 let mut idx = InstalledIndex::new();
3843 idx.upsert("gui", "0.2.0", "test");
3845 idx.upsert("aws", "0.1.0", "test");
3846
3847 let orphans = scan_orphan_store_dirs(&store, &idx);
3848 let names: Vec<(String, String)> = orphans.into_iter().map(|(n, v, _)| (n, v)).collect();
3849 assert_eq!(
3850 names,
3851 vec![
3852 ("gui".to_string(), "0.1.0".to_string()),
3853 ("gui".to_string(), "0.3.0".to_string()),
3854 ]
3855 );
3856 }
3857
3858 #[test]
3859 fn scan_orphan_store_dirs_flags_unindexed_names() {
3860 let root = tempdir("scan-orphans-unindexed");
3862 let store = Store::at(&root);
3863 store.ensure_layout().unwrap();
3864 std::fs::create_dir_all(store.package_dir("stale-pkg", "1.0.0")).unwrap();
3865 let idx = InstalledIndex::new();
3866 let orphans = scan_orphan_store_dirs(&store, &idx);
3867 assert_eq!(orphans.len(), 1);
3868 assert_eq!(orphans[0].0, "stale-pkg");
3869 }
3870
3871 #[test]
3872 fn cmd_use_global_switches_pin() {
3873 let _g = STRYKE_HOME_MUTEX.lock().unwrap();
3874 let root = tempdir("use-g-switch");
3875 std::env::set_var("STRYKE_HOME", &root);
3876 let store = Store::user_default().unwrap();
3877 store.ensure_layout().unwrap();
3878 std::fs::create_dir_all(store.package_dir("gui", "0.1.0")).unwrap();
3879 std::fs::create_dir_all(store.package_dir("gui", "0.2.0")).unwrap();
3880 let mut idx = InstalledIndex::new();
3881 idx.upsert("gui", "0.1.0", "test");
3882 idx.save_to(&store).unwrap();
3883
3884 let rc = cmd_use_global(&["gui@0.2.0".to_string()]);
3885 let reloaded = InstalledIndex::load_from(&store).unwrap();
3886 std::env::remove_var("STRYKE_HOME");
3887
3888 assert_eq!(rc, 0);
3889 assert_eq!(reloaded.find("gui").unwrap().version, "0.2.0");
3890 }
3891
3892 #[test]
3893 fn cmd_use_global_errors_when_store_missing() {
3894 let _g = STRYKE_HOME_MUTEX.lock().unwrap();
3895 let root = tempdir("use-g-missing");
3896 std::env::set_var("STRYKE_HOME", &root);
3897 let store = Store::user_default().unwrap();
3898 store.ensure_layout().unwrap();
3899 std::fs::create_dir_all(store.package_dir("gui", "0.1.0")).unwrap();
3901 let mut idx = InstalledIndex::new();
3902 idx.upsert("gui", "0.1.0", "test");
3903 idx.save_to(&store).unwrap();
3904
3905 let rc = cmd_use_global(&["gui@0.2.0".to_string()]);
3906 let reloaded = InstalledIndex::load_from(&store).unwrap();
3907 std::env::remove_var("STRYKE_HOME");
3908
3909 assert_eq!(rc, 1, "must fail when the version isn't in the store");
3910 assert_eq!(
3911 reloaded.find("gui").unwrap().version,
3912 "0.1.0",
3913 "pin must be untouched"
3914 );
3915 }
3916
3917 #[test]
3918 fn cmd_use_global_rejects_bad_spec() {
3919 let _g = STRYKE_HOME_MUTEX.lock().unwrap();
3920 let root = tempdir("use-g-bad-spec");
3921 std::env::set_var("STRYKE_HOME", &root);
3922 let rc = cmd_use_global(&["gui-without-version".to_string()]);
3923 std::env::remove_var("STRYKE_HOME");
3924 assert_eq!(rc, 1);
3925 }
3926
3927 #[test]
3928 fn cmd_gc_global_removes_orphans_only() {
3929 let _g = STRYKE_HOME_MUTEX.lock().unwrap();
3930 let root = tempdir("gc-g-removes");
3931 std::env::set_var("STRYKE_HOME", &root);
3932 let store = Store::user_default().unwrap();
3933 store.ensure_layout().unwrap();
3934 for v in ["0.1.0", "0.2.0"] {
3935 std::fs::create_dir_all(store.package_dir("gui", v)).unwrap();
3936 std::fs::write(store.package_dir("gui", v).join("marker"), b"x").unwrap();
3937 }
3938 let mut idx = InstalledIndex::new();
3939 idx.upsert("gui", "0.2.0", "test");
3940 idx.save_to(&store).unwrap();
3941
3942 let rc = cmd_gc_global(&[]);
3943 let pinned_exists = store.package_dir("gui", "0.2.0").is_dir();
3944 let orphan_gone = !store.package_dir("gui", "0.1.0").exists();
3945 std::env::remove_var("STRYKE_HOME");
3946
3947 assert_eq!(rc, 0);
3948 assert!(pinned_exists, "pinned version must remain");
3949 assert!(orphan_gone, "orphan must be removed");
3950 }
3951
3952 #[test]
3953 fn cmd_gc_global_dry_run_keeps_files() {
3954 let _g = STRYKE_HOME_MUTEX.lock().unwrap();
3955 let root = tempdir("gc-g-dry");
3956 std::env::set_var("STRYKE_HOME", &root);
3957 let store = Store::user_default().unwrap();
3958 store.ensure_layout().unwrap();
3959 std::fs::create_dir_all(store.package_dir("gui", "0.1.0")).unwrap();
3960 InstalledIndex::new().save_to(&store).unwrap();
3962
3963 let rc = cmd_gc_global(&["--dry-run".to_string()]);
3964 let still_present = store.package_dir("gui", "0.1.0").is_dir();
3965 std::env::remove_var("STRYKE_HOME");
3966
3967 assert_eq!(rc, 0);
3968 assert!(still_present, "--dry-run must not touch disk");
3969 }
3970
3971 #[test]
3972 fn installed_index_round_trip_via_save_load_from() {
3973 let root = tempdir("installed-index-cmds");
3978 let store = Store::at(&root);
3979 store.ensure_layout().unwrap();
3980 let mut idx = InstalledIndex::new();
3981 idx.upsert("gui", "0.2.0", "github:MenkeTechnologies/stryke-gui");
3982 idx.save_to(&store).unwrap();
3983 let reloaded = InstalledIndex::load_from(&store).unwrap();
3984 assert_eq!(reloaded.find("gui").unwrap().version, "0.2.0");
3985 }
3986
3987 #[test]
3988 fn install_spec_parses_gh_short() {
3989 match InstallSpec::parse("gh:MenkeTechnologies/stryke-gui").unwrap() {
3990 InstallSpec::GitHub {
3991 owner,
3992 repo,
3993 version,
3994 } => {
3995 assert_eq!(owner, "MenkeTechnologies");
3996 assert_eq!(repo, "stryke-gui");
3997 assert!(version.is_none());
3998 }
3999 _ => panic!("expected GitHub"),
4000 }
4001 }
4002
4003 #[test]
4004 fn install_spec_parses_https_with_version() {
4005 match InstallSpec::parse("https://github.com/foo/bar@v1.2.3").unwrap() {
4006 InstallSpec::GitHub {
4007 owner,
4008 repo,
4009 version,
4010 } => {
4011 assert_eq!(owner, "foo");
4012 assert_eq!(repo, "bar");
4013 assert_eq!(version.as_deref(), Some("v1.2.3"));
4014 }
4015 _ => panic!("expected GitHub"),
4016 }
4017 }
4018
4019 #[test]
4020 fn install_spec_strips_git_suffix() {
4021 match InstallSpec::parse("github.com/o/r.git").unwrap() {
4022 InstallSpec::GitHub { owner, repo, .. } => {
4023 assert_eq!(owner, "o");
4024 assert_eq!(repo, "r");
4025 }
4026 _ => panic!("expected GitHub"),
4027 }
4028 }
4029
4030 #[test]
4031 fn install_spec_unknown_scheme_is_path_if_dir() {
4032 let d = tempdir("install-spec-path");
4033 match InstallSpec::parse(d.to_str().unwrap()).unwrap() {
4034 InstallSpec::Path(p) => assert_eq!(p, PathBuf::from(d.to_str().unwrap())),
4035 _ => panic!("expected Path"),
4036 }
4037 }
4038
4039 #[test]
4040 fn install_spec_unknown_scheme_errors_if_not_dir() {
4041 let r = InstallSpec::parse("/definitely/not/a/dir/here");
4042 assert!(r.is_err());
4043 }
4044
4045 #[test]
4046 fn split_version_suffix_basic() {
4047 assert_eq!(
4048 split_version_suffix("gh:foo/bar@v1.0"),
4049 ("gh:foo/bar", Some("v1.0".into()))
4050 );
4051 assert_eq!(
4052 split_version_suffix("gh:foo/bar@0.2.0"),
4053 ("gh:foo/bar", Some("0.2.0".into()))
4054 );
4055 assert_eq!(split_version_suffix("gh:foo/bar"), ("gh:foo/bar", None));
4056 }
4057
4058 #[test]
4059 fn split_version_suffix_keeps_non_version_at() {
4060 assert_eq!(
4062 split_version_suffix("/Users/wizard/projects/team@alpha/pkg"),
4063 ("/Users/wizard/projects/team@alpha/pkg", None)
4064 );
4065 }
4066
4067 #[test]
4068 fn host_target_triple_honors_env() {
4069 std::env::set_var("STRYKE_TARGET", "x86_64-unknown-linux-musl");
4070 let t = host_target_triple().unwrap();
4071 std::env::remove_var("STRYKE_TARGET");
4072 assert_eq!(t, "x86_64-unknown-linux-musl");
4073 }
4074
4075 #[test]
4076 fn locate_manifest_dir_finds_root_level() {
4077 let d = tempdir("locate-root");
4078 std::fs::write(
4079 d.join(MANIFEST_FILE),
4080 "[package]\nname=\"x\"\nversion=\"0.1.0\"\n",
4081 )
4082 .unwrap();
4083 let r = locate_manifest_dir(&d).unwrap();
4084 assert_eq!(r, d);
4085 }
4086
4087 #[test]
4088 fn locate_manifest_dir_descends_single_top_dir() {
4089 let d = tempdir("locate-nested");
4090 let inner = d.join("stryke-gui-v0.2.0-aarch64-apple-darwin");
4091 std::fs::create_dir_all(&inner).unwrap();
4092 std::fs::write(
4093 inner.join(MANIFEST_FILE),
4094 "[package]\nname=\"gui\"\nversion=\"0.2.0\"\n",
4095 )
4096 .unwrap();
4097 let r = locate_manifest_dir(&d).unwrap();
4098 assert_eq!(r, inner);
4099 }
4100
4101 #[test]
4102 fn locate_manifest_dir_rejects_multiple_top_dirs() {
4103 let d = tempdir("locate-multi");
4104 std::fs::create_dir_all(d.join("a")).unwrap();
4105 std::fs::create_dir_all(d.join("b")).unwrap();
4106 assert!(locate_manifest_dir(&d).is_err());
4107 }
4108
4109 #[test]
4110 fn find_project_root_walks_up() {
4111 let root = tempdir("root");
4112 std::fs::write(
4113 root.join(MANIFEST_FILE),
4114 "[package]\nname=\"x\"\nversion=\"0.1.0\"\n",
4115 )
4116 .unwrap();
4117 let nested = root.join("a/b/c");
4118 std::fs::create_dir_all(&nested).unwrap();
4119 let canonical_root = root.canonicalize().unwrap();
4120 let canonical_nested = nested.canonicalize().unwrap();
4121 let found = find_project_root(&canonical_nested).unwrap();
4122 let canonical_found = found.canonicalize().unwrap();
4123 assert_eq!(canonical_found, canonical_root);
4124 }
4125
4126 #[test]
4127 fn resolve_module_local_lib_hit() {
4128 let root = tempdir("proj");
4129 std::fs::write(
4130 root.join(MANIFEST_FILE),
4131 "[package]\nname=\"x\"\nversion=\"0.1.0\"\n",
4132 )
4133 .unwrap();
4134 std::fs::create_dir_all(root.join("lib/Foo")).unwrap();
4135 std::fs::write(root.join("lib/Foo/Bar.stk"), "# bar").unwrap();
4136 let r = resolve_module(&root, "Foo::Bar", None).unwrap().unwrap();
4137 assert!(r.ends_with("lib/Foo/Bar.stk"), "got {:?}", r);
4138 }
4139
4140 #[test]
4148 fn resolve_module_local_lib_flat_layout_via_ffi_namespace() {
4149 let root = tempdir("proj-flat");
4150 std::fs::write(
4151 root.join(MANIFEST_FILE),
4152 "[package]\nname=\"stryke-arrow\"\nversion=\"0.1.0\"\n\
4153 [ffi]\nlib-name=\"stryke_arrow\"\nnamespace=\"Arrow\"\n",
4154 )
4155 .unwrap();
4156 std::fs::create_dir_all(root.join("lib")).unwrap();
4157 std::fs::write(root.join("lib/Parquet.stk"), b"# parquet").unwrap();
4158 let r = resolve_module(&root, "Arrow::Parquet", None)
4159 .unwrap()
4160 .unwrap();
4161 assert!(r.ends_with("lib/Parquet.stk"), "got {:?}", r);
4162 }
4163
4164 #[test]
4168 fn resolve_module_local_lib_flat_layout_case_insensitive() {
4169 let root = tempdir("proj-flat-case");
4170 std::fs::write(
4171 root.join(MANIFEST_FILE),
4172 "[package]\nname=\"stryke-aws\"\nversion=\"0.1.0\"\n\
4173 [ffi]\nlib-name=\"stryke_aws\"\nnamespace=\"AWS\"\n",
4174 )
4175 .unwrap();
4176 std::fs::create_dir_all(root.join("lib")).unwrap();
4177 std::fs::write(root.join("lib/S3.stk"), b"# s3").unwrap();
4178 let r = resolve_module(&root, "aws::S3", None).unwrap().unwrap();
4179 assert!(r.ends_with("lib/S3.stk"), "got {:?}", r);
4180 }
4181
4182 #[test]
4186 fn resolve_module_local_lib_flat_layout_only_fires_on_namespace_match() {
4187 let root = tempdir("proj-flat-mismatch");
4188 std::fs::write(
4189 root.join(MANIFEST_FILE),
4190 "[package]\nname=\"stryke-arrow\"\nversion=\"0.1.0\"\n\
4191 [ffi]\nlib-name=\"stryke_arrow\"\nnamespace=\"Arrow\"\n",
4192 )
4193 .unwrap();
4194 std::fs::create_dir_all(root.join("lib")).unwrap();
4195 std::fs::write(root.join("lib/Parquet.stk"), b"# parquet").unwrap();
4196 let r = resolve_module(&root, "Other::Parquet", None).unwrap();
4197 assert!(r.is_none(), "must not bridge across namespaces: {:?}", r);
4198 }
4199
4200 #[test]
4209 fn resolve_module_local_lib_flat_layout_via_stryke_prefix_pkg_name() {
4210 let root = tempdir("proj-flat-pure");
4211 std::fs::write(
4212 root.join(MANIFEST_FILE),
4213 "[package]\nname=\"stryke-utils\"\nversion=\"0.1.1\"\n",
4214 )
4215 .unwrap();
4216 std::fs::create_dir_all(root.join("lib")).unwrap();
4217 std::fs::write(root.join("lib/String.stk"), b"# string").unwrap();
4218 let r = resolve_module(&root, "Utils::String", None)
4219 .unwrap()
4220 .unwrap();
4221 assert!(r.ends_with("lib/String.stk"), "got {:?}", r);
4222 }
4223
4224 #[test]
4227 fn resolve_module_local_lib_flat_layout_pkg_name_bridge_scoped() {
4228 let root = tempdir("proj-flat-pure-mismatch");
4229 std::fs::write(
4230 root.join(MANIFEST_FILE),
4231 "[package]\nname=\"stryke-utils\"\nversion=\"0.1.1\"\n",
4232 )
4233 .unwrap();
4234 std::fs::create_dir_all(root.join("lib")).unwrap();
4235 std::fs::write(root.join("lib/Bar.stk"), b"# bar").unwrap();
4236 let r = resolve_module(&root, "Foo::Bar", None).unwrap();
4237 assert!(r.is_none(), "must not bridge unrelated namespaces: {:?}", r);
4238 }
4239
4240 #[test]
4241 fn resolve_module_falls_back_when_nothing_resolves() {
4242 let root = tempdir("proj");
4243 std::fs::write(
4244 root.join(MANIFEST_FILE),
4245 "[package]\nname=\"x\"\nversion=\"0.1.0\"\n",
4246 )
4247 .unwrap();
4248 let r = resolve_module(&root, "Foo::Bar", None).unwrap();
4249 assert!(r.is_none());
4250 }
4251
4252 #[test]
4258 fn resolve_module_resolves_via_stryke_prefix_when_namespace_empty() {
4259 let _g = STRYKE_HOME_MUTEX.lock().unwrap();
4260 let home = tempdir("stryke-home-prefix");
4261 std::env::set_var("STRYKE_HOME", &home);
4262
4263 let store = Store::user_default().unwrap();
4264 store.ensure_layout().unwrap();
4265 let pkg_dir = store.package_dir("stryke-gui", "0.3.0");
4266 std::fs::create_dir_all(pkg_dir.join("lib")).unwrap();
4267 std::fs::write(pkg_dir.join("lib/GUI.stk"), b"sub g { 1 }").unwrap();
4268
4269 let mut idx = InstalledIndex::new();
4270 idx.upsert("stryke-gui", "0.3.0", "github:MenkeTechnologies/stryke-gui");
4273 idx.save_to(&store).unwrap();
4274
4275 let proj = tempdir("proj-no-local-gui");
4276 let r = resolve_module(&proj, "GUI", None).unwrap();
4277 std::env::remove_var("STRYKE_HOME");
4278
4279 let r = r.expect("GUI should resolve to stryke-gui via prefix fallback");
4280 assert!(r.ends_with("store/stryke-gui@0.3.0/lib/GUI.stk"), "got {:?}", r);
4281 }
4282
4283 #[test]
4287 fn resolve_module_pin_version_lands_on_exact_store_dir() {
4288 let _g = STRYKE_HOME_MUTEX.lock().unwrap();
4289 let home = tempdir("stryke-home-pin-exact");
4290 std::env::set_var("STRYKE_HOME", &home);
4291
4292 let store = Store::user_default().unwrap();
4293 store.ensure_layout().unwrap();
4294 for v in ["1.0", "2.0"] {
4296 let pkg_dir = store.package_dir("foo", v);
4297 std::fs::create_dir_all(pkg_dir.join("lib")).unwrap();
4298 std::fs::write(
4299 pkg_dir.join("lib/Foo.stk"),
4300 format!("# foo {}\n", v).as_bytes(),
4301 )
4302 .unwrap();
4303 }
4304 let mut idx = InstalledIndex::new();
4305 idx.upsert("foo", "2.0", "test");
4306 idx.save_to(&store).unwrap();
4307
4308 let proj = tempdir("proj-pin-exact");
4309 let r = resolve_module(&proj, "Foo", Some("1.0")).unwrap();
4310 std::env::remove_var("STRYKE_HOME");
4311
4312 let r = r.expect("Foo 1.0 should resolve directly to foo@1.0");
4313 assert!(
4314 r.ends_with("store/foo@1.0/lib/Foo.stk"),
4315 "use-site pin must bypass installed.toml; got {:?}",
4316 r
4317 );
4318 }
4319
4320 #[test]
4324 fn resolve_module_pin_version_missing_errors_not_substitutes() {
4325 let _g = STRYKE_HOME_MUTEX.lock().unwrap();
4326 let home = tempdir("stryke-home-pin-missing");
4327 std::env::set_var("STRYKE_HOME", &home);
4328
4329 let store = Store::user_default().unwrap();
4330 store.ensure_layout().unwrap();
4331 let pkg_dir = store.package_dir("foo", "2.0");
4333 std::fs::create_dir_all(pkg_dir.join("lib")).unwrap();
4334 std::fs::write(pkg_dir.join("lib/Foo.stk"), b"# foo 2.0\n").unwrap();
4335 let mut idx = InstalledIndex::new();
4336 idx.upsert("foo", "2.0", "test");
4337 idx.save_to(&store).unwrap();
4338
4339 let proj = tempdir("proj-pin-missing");
4340 let r = resolve_module(&proj, "Foo", Some("1.0"));
4341 std::env::remove_var("STRYKE_HOME");
4342
4343 assert!(
4344 r.is_err(),
4345 "missing pin must error, not silently substitute 2.0; got {:?}",
4346 r
4347 );
4348 }
4349
4350 #[test]
4355 fn resolve_module_outside_project_picks_highest_version() {
4356 let _g = STRYKE_HOME_MUTEX.lock().unwrap();
4357 let home = tempdir("stryke-home-highest");
4358 std::env::set_var("STRYKE_HOME", &home);
4359
4360 let store = Store::user_default().unwrap();
4361 store.ensure_layout().unwrap();
4362 for v in ["1.99", "2.0", "0.5"] {
4363 let pkg_dir = store.package_dir("foo", v);
4364 std::fs::create_dir_all(pkg_dir.join("lib")).unwrap();
4365 std::fs::write(
4366 pkg_dir.join("lib/Foo.stk"),
4367 format!("# foo {}\n", v).as_bytes(),
4368 )
4369 .unwrap();
4370 }
4371 let proj = tempdir("proj-highest");
4375 let r = resolve_module(&proj, "Foo", None).unwrap();
4376 std::env::remove_var("STRYKE_HOME");
4377
4378 let r = r.expect("foo should resolve to highest version on disk");
4379 assert!(
4380 r.ends_with("store/foo@2.0/lib/Foo.stk"),
4381 "expected foo@2.0 (highest), got {:?}",
4382 r
4383 );
4384 }
4385
4386 #[test]
4390 fn resolve_module_highest_version_bridges_stryke_prefix() {
4391 let _g = STRYKE_HOME_MUTEX.lock().unwrap();
4392 let home = tempdir("stryke-home-bridge-highest");
4393 std::env::set_var("STRYKE_HOME", &home);
4394
4395 let store = Store::user_default().unwrap();
4396 store.ensure_layout().unwrap();
4397 for v in ["0.3.0", "0.10.0", "0.2.0"] {
4398 let pkg_dir = store.package_dir("stryke-gui", v);
4399 std::fs::create_dir_all(pkg_dir.join("lib")).unwrap();
4400 std::fs::write(
4401 pkg_dir.join("lib/GUI.stk"),
4402 format!("# gui {}\n", v).as_bytes(),
4403 )
4404 .unwrap();
4405 }
4406
4407 let proj = tempdir("proj-bridge-highest");
4408 let r = resolve_module(&proj, "GUI", None).unwrap();
4409 std::env::remove_var("STRYKE_HOME");
4410
4411 let r = r.expect("GUI should bridge to stryke-gui and pick highest");
4412 assert!(
4413 r.ends_with("store/stryke-gui@0.10.0/lib/GUI.stk"),
4414 "0.10.0 > 0.3.0 numerically — got {:?}",
4415 r
4416 );
4417 }
4418
4419 #[test]
4424 fn resolve_module_release_wins_over_pre_release() {
4425 let _g = STRYKE_HOME_MUTEX.lock().unwrap();
4426 let home = tempdir("stryke-home-prerelease");
4427 std::env::set_var("STRYKE_HOME", &home);
4428
4429 let store = Store::user_default().unwrap();
4430 store.ensure_layout().unwrap();
4431 for v in ["1.0.0-rc1", "1.0.0", "1.0.0-beta"] {
4433 let pkg_dir = store.package_dir("foo", v);
4434 std::fs::create_dir_all(pkg_dir.join("lib")).unwrap();
4435 std::fs::write(
4436 pkg_dir.join("lib/Foo.stk"),
4437 format!("# foo {}\n", v).as_bytes(),
4438 )
4439 .unwrap();
4440 }
4441
4442 let proj = tempdir("proj-prerelease");
4443 let r = resolve_module(&proj, "Foo", None).unwrap();
4444 std::env::remove_var("STRYKE_HOME");
4445
4446 let r = r.expect("foo should resolve");
4447 assert!(
4448 r.ends_with("foo@1.0.0/lib/Foo.stk"),
4449 "release 1.0.0 must beat 1.0.0-rc1 / 1.0.0-beta; got {:?}",
4450 r
4451 );
4452 }
4453
4454 #[test]
4460 fn resolve_module_pre_release_ordered_by_numeric_suffix() {
4461 let _g = STRYKE_HOME_MUTEX.lock().unwrap();
4462 let home = tempdir("stryke-home-prerelease-suffix");
4463 std::env::set_var("STRYKE_HOME", &home);
4464
4465 let store = Store::user_default().unwrap();
4466 store.ensure_layout().unwrap();
4467 for v in ["1.0.0-rc1", "1.0.0-rc2", "1.0.0-rc10"] {
4469 let pkg_dir = store.package_dir("foo", v);
4470 std::fs::create_dir_all(pkg_dir.join("lib")).unwrap();
4471 std::fs::write(
4472 pkg_dir.join("lib/Foo.stk"),
4473 format!("# foo {}\n", v).as_bytes(),
4474 )
4475 .unwrap();
4476 }
4477
4478 let proj = tempdir("proj-prerelease-suffix");
4479 let r = resolve_module(&proj, "Foo", None).unwrap();
4480 std::env::remove_var("STRYKE_HOME");
4481
4482 let r = r.expect("foo should resolve to highest pre-release");
4483 assert!(
4484 r.ends_with("foo@1.0.0-rc10/lib/Foo.stk"),
4485 "rc10 > rc2 > rc1 numerically; got {:?}",
4486 r
4487 );
4488 }
4489
4490 #[test]
4494 fn resolve_module_lockfile_pin_missing_errors_not_substitutes() {
4495 let _g = STRYKE_HOME_MUTEX.lock().unwrap();
4496 let home = tempdir("stryke-home-lock-missing");
4497 std::env::set_var("STRYKE_HOME", &home);
4498
4499 let store = Store::user_default().unwrap();
4500 store.ensure_layout().unwrap();
4501 let pkg_dir = store.package_dir("foo", "2.0");
4504 std::fs::create_dir_all(pkg_dir.join("lib")).unwrap();
4505 std::fs::write(pkg_dir.join("lib/Foo.stk"), b"# foo 2.0\n").unwrap();
4506 let mut idx = InstalledIndex::new();
4507 idx.upsert("foo", "2.0", "test");
4508 idx.save_to(&store).unwrap();
4509
4510 let proj = tempdir("proj-lock-missing");
4511 std::fs::write(
4512 proj.join(MANIFEST_FILE),
4513 "[package]\nname=\"x\"\nversion=\"0.1.0\"\n[deps]\nfoo = \"1.0\"\n",
4514 )
4515 .unwrap();
4516 let mut lf = Lockfile::new();
4517 lf.packages.push(super::super::lockfile::LockedPackage {
4518 name: "foo".into(),
4519 version: "1.0".into(),
4520 source: "registry+test".into(),
4521 integrity: "sha256-deadbeef".into(),
4522 features: vec![],
4523 deps: vec![],
4524 });
4525 std::fs::write(
4526 proj.join(LOCKFILE_FILE),
4527 lf.to_toml_string().unwrap(),
4528 )
4529 .unwrap();
4530
4531 let r = resolve_module(&proj, "Foo", None);
4532 std::env::remove_var("STRYKE_HOME");
4533
4534 assert!(
4535 r.is_err(),
4536 "lockfile pin missing in store must error, not silently use foo@2.0; got {:?}",
4537 r
4538 );
4539 }
4540
4541 fn fake_installed_pkg(
4555 store: &Store,
4556 name: &str,
4557 version: &str,
4558 namespace: &str,
4559 subs: &[&str],
4560 ) -> PathBuf {
4561 let pkg = store.package_dir(name, version);
4562 std::fs::create_dir_all(pkg.join("lib")).unwrap();
4563 let mut stk = format!("package {}\n\n", namespace);
4564 for s in subs {
4565 stk.push_str(&format!("## Mock docstring for {}::{}.\n", namespace, s));
4566 stk.push_str(&format!("fn {}::{} {{ 1 }}\n\n", namespace, s));
4567 }
4568 std::fs::write(pkg.join("lib").join(format!("{}.stk", namespace)), stk).unwrap();
4569 std::fs::write(
4570 pkg.join(MANIFEST_FILE),
4571 format!(
4572 "[package]\nname=\"{}\"\nversion=\"{}\"\n\n\
4573 [ffi]\nlib-name=\"x\"\nnamespace=\"{}\"\nexports=[]\n",
4574 name, version, namespace
4575 ),
4576 )
4577 .unwrap();
4578 pkg
4579 }
4580
4581 #[test]
4584 fn resolve_require_path_resolves_installed_package() {
4585 let _g = STRYKE_HOME_MUTEX.lock().unwrap();
4586 let home = tempdir("resolve-installed");
4587 std::env::set_var("STRYKE_HOME", &home);
4588
4589 let store = Store::user_default().unwrap();
4590 store.ensure_layout().unwrap();
4591 let pkg_dir = fake_installed_pkg(&store, "gui", "1.0.0", "GUI", &["mouse_pos"]);
4592 let mut idx = InstalledIndex::new();
4593 idx.upsert("gui", "1.0.0", "test");
4594 idx.save_to(&store).unwrap();
4595
4596 let script_dir = tempdir("resolve-script");
4599 let script_path = script_dir.join("script.stk");
4600 std::fs::write(&script_path, "use GUI\n").unwrap();
4601
4602 let resolved = crate::static_analysis::resolve_require_path_from_file(
4603 script_path.to_str().unwrap(),
4604 "GUI",
4605 );
4606 std::env::remove_var("STRYKE_HOME");
4607
4608 assert_eq!(
4609 resolved.as_deref(),
4610 Some(pkg_dir.join("lib/GUI.stk").as_path())
4611 );
4612 }
4613
4614 #[test]
4617 fn resolve_require_path_local_shadows_installed() {
4618 let _g = STRYKE_HOME_MUTEX.lock().unwrap();
4619 let home = tempdir("resolve-shadow");
4620 std::env::set_var("STRYKE_HOME", &home);
4621
4622 let store = Store::user_default().unwrap();
4623 store.ensure_layout().unwrap();
4624 fake_installed_pkg(&store, "gui", "1.0.0", "GUI", &["mouse_pos"]);
4625 let mut idx = InstalledIndex::new();
4626 idx.upsert("gui", "1.0.0", "test");
4627 idx.save_to(&store).unwrap();
4628
4629 let proj = tempdir("resolve-proj");
4631 std::fs::write(
4632 proj.join(MANIFEST_FILE),
4633 "[package]\nname=\"x\"\nversion=\"0.1.0\"\n",
4634 )
4635 .unwrap();
4636 std::fs::create_dir_all(proj.join("lib")).unwrap();
4637 let local_gui = proj.join("lib/GUI.stk");
4638 std::fs::write(&local_gui, "package GUI\nfn GUI::local_only { 1 }\n").unwrap();
4639
4640 let script_path = proj.join("main.stk");
4641 std::fs::write(&script_path, "use GUI\n").unwrap();
4642
4643 let resolved = crate::static_analysis::resolve_require_path_from_file(
4644 script_path.to_str().unwrap(),
4645 "GUI",
4646 );
4647 std::env::remove_var("STRYKE_HOME");
4648
4649 assert_eq!(
4651 resolved.as_deref().and_then(|p| p.canonicalize().ok()),
4652 local_gui.canonicalize().ok()
4653 );
4654 }
4655
4656 #[test]
4659 fn resolve_require_path_lockfile_wins_over_global_pin() {
4660 let _g = STRYKE_HOME_MUTEX.lock().unwrap();
4661 let home = tempdir("resolve-lock");
4662 std::env::set_var("STRYKE_HOME", &home);
4663
4664 let store = Store::user_default().unwrap();
4665 store.ensure_layout().unwrap();
4666 let pinned = fake_installed_pkg(&store, "gui", "0.2.0", "GUI", &["mouse_pos"]);
4667 let _newer = fake_installed_pkg(&store, "gui", "0.1.0", "GUI", &["mouse_pos"]);
4668
4669 let mut idx = InstalledIndex::new();
4671 idx.upsert("gui", "0.2.0", "test");
4672 idx.save_to(&store).unwrap();
4673
4674 let proj = tempdir("resolve-lock-proj");
4675 std::fs::write(
4676 proj.join(MANIFEST_FILE),
4677 "[package]\nname=\"x\"\nversion=\"0.1.0\"\n",
4678 )
4679 .unwrap();
4680 std::fs::write(
4681 proj.join(LOCKFILE_FILE),
4682 "version = 1\nstryke = \"0.0.0\"\nresolved = \"2026-01-01T00:00:00Z\"\n\n\
4683 [[package]]\nname=\"gui\"\nversion=\"0.1.0\"\nsource=\"path+file:///x\"\n\
4684 integrity=\"sha256-0\"\nfeatures=[]\ndeps=[]\n",
4685 )
4686 .unwrap();
4687
4688 let script_path = proj.join("main.stk");
4689 std::fs::write(&script_path, "use GUI\n").unwrap();
4690
4691 let resolved = crate::static_analysis::resolve_require_path_from_file(
4692 script_path.to_str().unwrap(),
4693 "GUI",
4694 );
4695 std::env::remove_var("STRYKE_HOME");
4696
4697 let expected_v01 = store.package_dir("gui", "0.1.0").join("lib/GUI.stk");
4700 let pinned_v02 = pinned.join("lib/GUI.stk");
4701 assert_eq!(resolved.as_deref(), Some(expected_v01.as_path()));
4702 assert_ne!(resolved.as_deref(), Some(pinned_v02.as_path()));
4703 }
4704
4705 #[test]
4708 fn installed_package_completions_lists_all_when_unfiltered() {
4709 let _g = STRYKE_HOME_MUTEX.lock().unwrap();
4710 let home = tempdir("compl-all");
4711 std::env::set_var("STRYKE_HOME", &home);
4712
4713 let store = Store::user_default().unwrap();
4714 store.ensure_layout().unwrap();
4715 fake_installed_pkg(&store, "gui", "1.0.0", "GUI", &[]);
4716 fake_installed_pkg(&store, "aws", "1.0.0", "AWS", &[]);
4717 let mut idx = InstalledIndex::new();
4718 idx.upsert("gui", "1.0.0", "test");
4719 idx.upsert("aws", "1.0.0", "test");
4720 idx.save_to(&store).unwrap();
4721
4722 let items = crate::lsp::installed_package_completions("");
4723 std::env::remove_var("STRYKE_HOME");
4724
4725 let labels: Vec<String> = items.iter().map(|c| c.label.clone()).collect();
4726 assert!(labels.iter().any(|l| l == "GUI"), "got: {:?}", labels);
4727 assert!(labels.iter().any(|l| l == "AWS"), "got: {:?}", labels);
4728 }
4729
4730 #[test]
4733 fn installed_package_completions_case_insensitive_prefix() {
4734 let _g = STRYKE_HOME_MUTEX.lock().unwrap();
4735 let home = tempdir("compl-prefix");
4736 std::env::set_var("STRYKE_HOME", &home);
4737
4738 let store = Store::user_default().unwrap();
4739 store.ensure_layout().unwrap();
4740 fake_installed_pkg(&store, "gui", "1.0.0", "GUI", &[]);
4741 fake_installed_pkg(&store, "aws", "1.0.0", "AWS", &[]);
4742 let mut idx = InstalledIndex::new();
4743 idx.upsert("gui", "1.0.0", "test");
4744 idx.upsert("aws", "1.0.0", "test");
4745 idx.save_to(&store).unwrap();
4746
4747 let lower = crate::lsp::installed_package_completions("gu");
4748 let upper = crate::lsp::installed_package_completions("Gu");
4749 std::env::remove_var("STRYKE_HOME");
4750
4751 assert_eq!(lower.len(), 1);
4752 assert_eq!(lower[0].label, "GUI");
4753 assert_eq!(upper.len(), 1);
4754 assert_eq!(upper[0].label, "GUI");
4755 }
4756
4757 #[test]
4761 fn installed_package_completions_falls_back_to_package_decl() {
4762 let _g = STRYKE_HOME_MUTEX.lock().unwrap();
4763 let home = tempdir("compl-fallback");
4764 std::env::set_var("STRYKE_HOME", &home);
4765
4766 let store = Store::user_default().unwrap();
4767 store.ensure_layout().unwrap();
4768 let pkg = store.package_dir("mypkg", "1.0.0");
4769 std::fs::create_dir_all(pkg.join("lib")).unwrap();
4770 std::fs::write(
4773 pkg.join(MANIFEST_FILE),
4774 "[package]\nname=\"mypkg\"\nversion=\"1.0.0\"\n",
4775 )
4776 .unwrap();
4777 std::fs::write(
4778 pkg.join("lib/MyPkg.stk"),
4779 "package MyPkg\nfn MyPkg::hello { 1 }\n",
4780 )
4781 .unwrap();
4782 let mut idx = InstalledIndex::new();
4783 idx.upsert("mypkg", "1.0.0", "test");
4784 idx.save_to(&store).unwrap();
4785
4786 let items = crate::lsp::installed_package_completions("");
4787 std::env::remove_var("STRYKE_HOME");
4788
4789 let labels: Vec<String> = items.iter().map(|c| c.label.clone()).collect();
4790 assert!(labels.iter().any(|l| l == "MyPkg"), "got: {:?}", labels);
4791 }
4792
4793 #[test]
4796 fn line_completion_is_use_context_recognizes_keywords() {
4797 let line = "use Gui";
4798 assert!(crate::lsp::line_completion_is_use_context(line, line.len()));
4799 let line = "require Foo";
4800 assert!(crate::lsp::line_completion_is_use_context(line, line.len()));
4801 let line = " use Foo::Bar";
4802 assert!(crate::lsp::line_completion_is_use_context(line, line.len()));
4803 let line = "use ";
4804 assert!(crate::lsp::line_completion_is_use_context(line, line.len()));
4805 }
4806
4807 #[test]
4811 fn line_completion_is_use_context_rejects_non_use_and_args() {
4812 let line = "my $x = 1";
4813 assert!(!crate::lsp::line_completion_is_use_context(
4814 line,
4815 line.len()
4816 ));
4817 let line = "use overload '+'";
4818 assert!(!crate::lsp::line_completion_is_use_context(
4819 line,
4820 line.len()
4821 ));
4822 let line = "GUI::mouse_pos";
4823 assert!(!crate::lsp::line_completion_is_use_context(
4824 line,
4825 line.len()
4826 ));
4827 let line = "p use_count";
4828 assert!(!crate::lsp::line_completion_is_use_context(
4829 line,
4830 line.len()
4831 ));
4832 }
4833
4834 #[test]
4844 fn analyzer_chases_use_into_installed_package() {
4845 let _g = STRYKE_HOME_MUTEX.lock().unwrap();
4846 let home = tempdir("analyze-use-chase");
4847 std::env::set_var("STRYKE_HOME", &home);
4848
4849 let store = Store::user_default().unwrap();
4850 store.ensure_layout().unwrap();
4851 fake_installed_pkg(
4852 &store,
4853 "gui",
4854 "1.0.0",
4855 "GUI",
4856 &["mouse_pos", "keyboard_keys"],
4857 );
4858 let mut idx = InstalledIndex::new();
4859 idx.upsert("gui", "1.0.0", "test");
4860 idx.save_to(&store).unwrap();
4861
4862 let script_dir = tempdir("analyze-use-script");
4866 let script_path = script_dir.join("main.stk");
4867 let text = "use GUI\nmy ($x, $y) = GUI::mouse_pos()\nmy @k = GUI::keyboard_keys()\n";
4868 std::fs::write(&script_path, text).unwrap();
4869
4870 let program = crate::parse_with_file(text, script_path.to_str().unwrap())
4871 .expect("script should parse");
4872 let result = crate::static_analysis::analyze_program_with_strict(
4875 &program,
4876 script_path.to_str().unwrap(),
4877 true,
4878 );
4879 std::env::remove_var("STRYKE_HOME");
4880
4881 if let Err(e) = result {
4882 panic!(
4883 "linter should accept GUI::* calls after `use GUI` chases into \
4884 the installed package, but got: kind={:?} message={}",
4885 e.kind, e.message
4886 );
4887 }
4888 }
4889
4890 #[test]
4900 fn analyzer_pre_declares_ffi_exports_when_chasing_into_package() {
4901 let _g = STRYKE_HOME_MUTEX.lock().unwrap();
4902 let home = tempdir("analyze-ffi-exports");
4903 std::env::set_var("STRYKE_HOME", &home);
4904
4905 let store = Store::user_default().unwrap();
4906 store.ensure_layout().unwrap();
4907
4908 let pkg = store.package_dir("gui", "1.0.0");
4914 std::fs::create_dir_all(pkg.join("lib")).unwrap();
4915 std::fs::write(
4916 pkg.join("stryke.toml"),
4917 "[package]\nname=\"gui\"\nversion=\"1.0.0\"\n\n\
4918 [ffi]\nlib-name=\"x\"\nnamespace=\"GUI\"\n\
4919 exports=[\"gui__mouse_pos\", \"gui__keyboard_keys\"]\n",
4920 )
4921 .unwrap();
4922 std::fs::write(
4923 pkg.join("lib/GUI.stk"),
4924 "package GUI\n\n\
4925 fn GUI::mouse_pos { gui__mouse_pos(\"{}\") }\n\
4926 fn GUI::keyboard_keys { gui__keyboard_keys(\"{}\") }\n",
4927 )
4928 .unwrap();
4929
4930 let mut idx = InstalledIndex::new();
4931 idx.upsert("gui", "1.0.0", "test");
4932 idx.save_to(&store).unwrap();
4933
4934 let script_dir = tempdir("analyze-ffi-script");
4936 let script_path = script_dir.join("main.stk");
4937 let text = "use GUI\nmy ($x, $y) = GUI::mouse_pos()\n";
4938 std::fs::write(&script_path, text).unwrap();
4939
4940 let program = crate::parse_with_file(text, script_path.to_str().unwrap())
4941 .expect("script should parse");
4942 let result = crate::static_analysis::analyze_program_with_strict(
4943 &program,
4944 script_path.to_str().unwrap(),
4945 true,
4946 );
4947 std::env::remove_var("STRYKE_HOME");
4948
4949 if let Err(e) = result {
4950 panic!(
4951 "linter should pre-declare [ffi].exports when chasing into the \
4952 cdylib package — gui__mouse_pos / gui__keyboard_keys must NOT \
4953 fire UndefinedSubroutine. Got: kind={:?} message={}",
4954 e.kind, e.message
4955 );
4956 }
4957 }
4958
4959 #[test]
4967 fn hover_chases_use_to_show_package_module_docs() {
4968 let _g = STRYKE_HOME_MUTEX.lock().unwrap();
4969 let home = tempdir("hover-pkg-module-docs");
4970 std::env::set_var("STRYKE_HOME", &home);
4971
4972 let store = Store::user_default().unwrap();
4973 store.ensure_layout().unwrap();
4974
4975 let pkg = store.package_dir("gui", "1.0.0");
4979 std::fs::create_dir_all(pkg.join("lib")).unwrap();
4980 std::fs::write(
4981 pkg.join("stryke.toml"),
4982 "[package]\nname=\"gui\"\nversion=\"1.0.0\"\n\n\
4983 [ffi]\nlib-name=\"x\"\nnamespace=\"GUI\"\nexports=[]\n",
4984 )
4985 .unwrap();
4986 std::fs::write(
4987 pkg.join("lib/GUI.stk"),
4988 "## lib/GUI.stk — GUI automation for stryke (`use GUI`).\n\
4989 ##\n\
4990 ## Thin wrapper around the stryke-gui cdylib's exports.\n\
4991 \n\
4992 package GUI\n\
4993 \n\
4994 fn GUI::mouse_pos { 1 }\n",
4995 )
4996 .unwrap();
4997
4998 let mut idx = InstalledIndex::new();
4999 idx.upsert("gui", "1.0.0", "test");
5000 idx.save_to(&store).unwrap();
5001
5002 let script_dir = tempdir("hover-pkg-script");
5005 let script_path = script_dir.join("main.stk");
5006 let text = "use GUI\n";
5007 std::fs::write(&script_path, text).unwrap();
5008
5009 let hover = crate::lsp::hover_markdown_for_word("GUI", text, script_path.to_str().unwrap());
5010 std::env::remove_var("STRYKE_HOME");
5011
5012 let md = hover.expect("hover should resolve the GUI package via cross-file chase");
5013 assert!(
5014 md.contains("GUI automation"),
5015 "package hover must surface the file-header `## …` block: {}",
5016 md
5017 );
5018 assert!(
5019 md.contains("Thin wrapper around the stryke-gui cdylib"),
5020 "multi-line file header must come through intact: {}",
5021 md
5022 );
5023 assert!(
5024 md.contains("declared in"),
5025 "header line ('declared in <path> at line N') should still be appended: {}",
5026 md
5027 );
5028 }
5029
5030 #[test]
5036 fn hover_chases_use_to_show_fn_docs_with_blank_separator() {
5037 let _g = STRYKE_HOME_MUTEX.lock().unwrap();
5038 let home = tempdir("hover-fn-blank-sep");
5039 std::env::set_var("STRYKE_HOME", &home);
5040
5041 let store = Store::user_default().unwrap();
5042 store.ensure_layout().unwrap();
5043
5044 let pkg = store.package_dir("gui", "1.0.0");
5045 std::fs::create_dir_all(pkg.join("lib")).unwrap();
5046 std::fs::write(
5047 pkg.join("stryke.toml"),
5048 "[package]\nname=\"gui\"\nversion=\"1.0.0\"\n\n\
5049 [ffi]\nlib-name=\"x\"\nnamespace=\"GUI\"\n\
5050 exports=[\"gui__mouse_pos\"]\n",
5051 )
5052 .unwrap();
5053 std::fs::write(
5055 pkg.join("lib/GUI.stk"),
5056 "package GUI\n\n\
5057 ## Current cursor position → ($x, $y).\n\
5058 \n\
5059 fn GUI::mouse_pos { gui__mouse_pos(\"{}\") }\n",
5060 )
5061 .unwrap();
5062
5063 let mut idx = InstalledIndex::new();
5064 idx.upsert("gui", "1.0.0", "test");
5065 idx.save_to(&store).unwrap();
5066
5067 let script_dir = tempdir("hover-fn-script");
5068 let script_path = script_dir.join("main.stk");
5069 let text = "use GUI\nmy ($x, $y) = GUI::mouse_pos()\n";
5070 std::fs::write(&script_path, text).unwrap();
5071
5072 let hover = crate::lsp::hover_markdown_for_word(
5073 "GUI::mouse_pos",
5074 text,
5075 script_path.to_str().unwrap(),
5076 );
5077 std::env::remove_var("STRYKE_HOME");
5078
5079 let md = hover.expect("hover should resolve `GUI::mouse_pos` cross-file");
5080 assert!(
5081 md.contains("Current cursor position"),
5082 "fn hover must surface the `##` docstring even with a blank \
5083 separator above the `fn` decl: {}",
5084 md
5085 );
5086 }
5087
5088 #[test]
5091 fn hover_chases_use_directive_into_store() {
5092 let _g = STRYKE_HOME_MUTEX.lock().unwrap();
5093 let home = tempdir("hover-cross");
5094 std::env::set_var("STRYKE_HOME", &home);
5095
5096 let store = Store::user_default().unwrap();
5097 store.ensure_layout().unwrap();
5098 fake_installed_pkg(&store, "gui", "1.0.0", "GUI", &["mouse_pos"]);
5099 let mut idx = InstalledIndex::new();
5100 idx.upsert("gui", "1.0.0", "test");
5101 idx.save_to(&store).unwrap();
5102
5103 let script_dir = tempdir("hover-script");
5105 let script_path = script_dir.join("main.stk");
5106 let text = "use GUI\n";
5107 std::fs::write(&script_path, text).unwrap();
5108
5109 let hover = crate::lsp::hover_markdown_for_word(
5110 "GUI::mouse_pos",
5111 text,
5112 script_path.to_str().unwrap(),
5113 );
5114 std::env::remove_var("STRYKE_HOME");
5115
5116 let md = hover.expect("hover should resolve `GUI::mouse_pos` cross-file");
5117 assert!(
5119 md.contains("Mock docstring for GUI::mouse_pos"),
5120 "hover did not include the chased doc: {}",
5121 md
5122 );
5123 }
5124
5125 #[test]
5134 fn github_shorthand_bare_form() {
5135 let gh = parse_github_shorthand("github.com/MenkeTechnologies/stryke-parquet").unwrap();
5136 assert_eq!(gh.name, "stryke-parquet");
5137 assert_eq!(gh.owner_repo, "MenkeTechnologies/stryke-parquet");
5138 assert!(gh.tag.is_none());
5139 }
5140
5141 #[test]
5142 fn github_shorthand_https_form() {
5143 let gh =
5144 parse_github_shorthand("https://github.com/MenkeTechnologies/stryke-aws").unwrap();
5145 assert_eq!(gh.name, "stryke-aws");
5146 assert_eq!(gh.owner_repo, "MenkeTechnologies/stryke-aws");
5147 assert!(gh.tag.is_none());
5148 }
5149
5150 #[test]
5151 fn github_shorthand_strips_dot_git() {
5152 let gh =
5153 parse_github_shorthand("https://github.com/MenkeTechnologies/stryke-aws.git").unwrap();
5154 assert_eq!(gh.name, "stryke-aws");
5155 assert_eq!(gh.owner_repo, "MenkeTechnologies/stryke-aws");
5156 }
5157
5158 #[test]
5159 fn github_shorthand_with_tag() {
5160 let gh =
5161 parse_github_shorthand("github.com/MenkeTechnologies/stryke-aws@v0.2.0").unwrap();
5162 assert_eq!(gh.name, "stryke-aws");
5163 assert_eq!(gh.owner_repo, "MenkeTechnologies/stryke-aws");
5164 assert_eq!(gh.tag.as_deref(), Some("v0.2.0"));
5165 }
5166
5167 #[test]
5168 fn github_shorthand_dot_git_with_tag() {
5169 let gh = parse_github_shorthand(
5170 "https://github.com/MenkeTechnologies/stryke-aws.git@v0.2.0",
5171 )
5172 .unwrap();
5173 assert_eq!(gh.name, "stryke-aws");
5174 assert_eq!(gh.tag.as_deref(), Some("v0.2.0"));
5175 }
5176
5177 #[test]
5178 fn github_shorthand_rejects_non_github() {
5179 assert!(parse_github_shorthand("serde").is_none());
5181 assert!(parse_github_shorthand("http@1.0").is_none());
5182 assert!(parse_github_shorthand("gitlab.com/foo/bar").is_none());
5184 assert!(parse_github_shorthand("https://gitlab.com/foo/bar").is_none());
5185 }
5186
5187 #[test]
5188 fn github_shorthand_rejects_subpath() {
5189 assert!(parse_github_shorthand("github.com/owner/repo/subdir").is_none());
5192 assert!(parse_github_shorthand("github.com/owner/repo/tree/main").is_none());
5193 }
5194
5195 #[test]
5196 fn github_shorthand_rejects_empty_owner_or_repo() {
5197 assert!(parse_github_shorthand("github.com/").is_none());
5198 assert!(parse_github_shorthand("github.com//repo").is_none());
5199 assert!(parse_github_shorthand("github.com/owner/").is_none());
5200 assert!(parse_github_shorthand("github.com/owner/.git").is_none());
5201 }
5202
5203 #[test]
5204 fn github_shorthand_rejects_empty_tag() {
5205 assert!(parse_github_shorthand("github.com/owner/repo@").is_none());
5207 }
5208
5209 #[test]
5212 fn parse_add_args_github_shorthand_produces_github_release_dep() {
5213 let args = vec!["github.com/MenkeTechnologies/stryke-parquet".to_string()];
5214 let parsed = parse_add_args(&args).expect("parse should succeed");
5215 assert_eq!(parsed.name, "stryke-parquet");
5216 match parsed.spec {
5217 DepSpec::Detailed(d) => {
5218 assert_eq!(
5219 d.github.as_deref(),
5220 Some("MenkeTechnologies/stryke-parquet")
5221 );
5222 assert!(d.version.is_none());
5224 assert!(d.git.is_none(), "github shorthand must not also set git");
5228 assert!(d.branch.is_none());
5229 assert!(d.path.is_none());
5230 }
5231 other => panic!("expected DepSpec::Detailed github dep, got {:?}", other),
5232 }
5233 }
5234
5235 #[test]
5236 fn parse_add_args_github_shorthand_with_tag_pins_version() {
5237 let args = vec!["github.com/MenkeTechnologies/stryke-aws@v0.2.0".to_string()];
5238 let parsed = parse_add_args(&args).expect("parse should succeed");
5239 assert_eq!(parsed.name, "stryke-aws");
5240 match parsed.spec {
5241 DepSpec::Detailed(d) => {
5242 assert_eq!(
5243 d.github.as_deref(),
5244 Some("MenkeTechnologies/stryke-aws")
5245 );
5246 assert_eq!(d.version.as_deref(), Some("v0.2.0"));
5250 assert!(d.git.is_none());
5251 }
5252 other => panic!("expected DepSpec::Detailed github dep, got {:?}", other),
5253 }
5254 }
5255
5256 #[test]
5257 fn parse_add_args_path_flag_wins_over_github_shorthand() {
5258 let args = vec![
5261 "github.com/MenkeTechnologies/stryke-aws".to_string(),
5262 "--path=../local-aws".to_string(),
5263 ];
5264 let parsed = parse_add_args(&args).expect("parse should succeed");
5265 assert_eq!(parsed.name, "stryke-aws");
5266 match parsed.spec {
5267 DepSpec::Detailed(d) => {
5268 assert_eq!(d.path.as_deref(), Some("../local-aws"));
5269 assert!(d.git.is_none(), "git must be None when --path overrides");
5270 }
5271 other => panic!("expected DepSpec::Detailed path dep, got {:?}", other),
5272 }
5273 }
5274
5275 #[test]
5276 fn parse_add_args_registry_form_still_returns_version_spec() {
5277 let args = vec!["http@1.0".to_string()];
5279 let parsed = parse_add_args(&args).expect("parse should succeed");
5280 assert_eq!(parsed.name, "http");
5281 match parsed.spec {
5282 DepSpec::Version(v) => assert_eq!(v, "1.0"),
5283 other => panic!("expected DepSpec::Version, got {:?}", other),
5284 }
5285 }
5286
5287 #[test]
5296 fn local_path_arg_relative_dot_slash() {
5297 let lp =
5298 parse_local_path_arg("./mylib").expect("./mylib should parse as path");
5299 assert_eq!(lp.name, "mylib");
5300 assert_eq!(lp.path_for_manifest, "./mylib");
5301 }
5302
5303 #[test]
5304 fn local_path_arg_relative_parent() {
5305 let lp =
5306 parse_local_path_arg("../sibling").expect("../sibling should parse as path");
5307 assert_eq!(lp.name, "sibling");
5308 assert_eq!(lp.path_for_manifest, "../sibling");
5309 }
5310
5311 #[test]
5312 fn local_path_arg_absolute_nonexistent_still_accepted() {
5313 let lp = parse_local_path_arg("/tmp/will-create-later")
5316 .expect("absolute sigil should parse as path");
5317 assert_eq!(lp.name, "will-create-later");
5318 assert_eq!(lp.path_for_manifest, "/tmp/will-create-later");
5319 }
5320
5321 #[test]
5322 fn local_path_arg_tilde_expands_in_manifest() {
5323 let home = std::env::var("HOME").expect("HOME set in test env");
5324 let lp = parse_local_path_arg("~/projects/mylib")
5325 .expect("~/PATH should parse as path");
5326 assert_eq!(lp.name, "mylib");
5327 assert_eq!(
5328 lp.path_for_manifest,
5329 format!("{}/projects/mylib", home.trim_end_matches('/'))
5330 );
5331 }
5332
5333 #[test]
5334 fn local_path_arg_reads_pkg_name_when_manifest_present() {
5335 let d = tempdir("path-arg-name");
5340 let pkg_dir = d.join("crates").join("foo");
5341 std::fs::create_dir_all(&pkg_dir).unwrap();
5342 std::fs::write(
5343 pkg_dir.join(MANIFEST_FILE),
5344 "[package]\nname=\"foo-lib\"\nversion=\"0.1.0\"\n",
5345 )
5346 .unwrap();
5347
5348 let raw = pkg_dir.to_str().unwrap();
5349 let lp = parse_local_path_arg(raw).expect("existing dir should parse as path");
5350 assert_eq!(lp.name, "foo-lib");
5351 assert_eq!(lp.path_for_manifest, raw);
5352 }
5353
5354 #[test]
5355 fn local_path_arg_existing_dir_without_sigil_auto_detected() {
5356 let d = tempdir("path-arg-bare-existing");
5358 let pkg_dir = d.join("mylib");
5359 std::fs::create_dir_all(&pkg_dir).unwrap();
5360 std::fs::write(
5361 pkg_dir.join(MANIFEST_FILE),
5362 "[package]\nname=\"mylib\"\nversion=\"0.1.0\"\n",
5363 )
5364 .unwrap();
5365
5366 let lp =
5367 parse_local_path_arg(pkg_dir.to_str().unwrap()).expect("existing dir → path dep");
5368 assert_eq!(lp.name, "mylib");
5369 }
5370
5371 #[test]
5372 fn local_path_arg_rejects_registry_names() {
5373 assert!(parse_local_path_arg("serde").is_none());
5377 assert!(parse_local_path_arg("stryke-arrow").is_none());
5378 }
5379
5380 #[test]
5381 fn local_path_arg_rejects_version_suffixed_names() {
5382 assert!(parse_local_path_arg("http@1.0").is_none());
5384 assert!(parse_local_path_arg("./mylib@1.0").is_none());
5385 }
5386
5387 #[test]
5388 fn local_path_arg_rejects_url_like_shapes() {
5389 assert!(parse_local_path_arg("file:///tmp/mylib").is_none());
5391 assert!(parse_local_path_arg("https://example.com/mylib").is_none());
5392 }
5393
5394 #[test]
5395 fn parse_add_args_relative_path_becomes_path_dep() {
5396 let args = vec!["./mylib".to_string()];
5397 let parsed = parse_add_args(&args).expect("parse should succeed");
5398 assert_eq!(parsed.name, "mylib");
5399 match parsed.spec {
5400 DepSpec::Detailed(d) => {
5401 assert_eq!(d.path.as_deref(), Some("./mylib"));
5402 assert!(d.git.is_none());
5403 assert!(d.version.is_none());
5404 }
5405 other => panic!("expected DepSpec::Detailed path dep, got {:?}", other),
5406 }
5407 }
5408
5409 #[test]
5410 fn parse_add_args_absolute_path_becomes_path_dep() {
5411 let args = vec!["/work/vendored/mylib".to_string()];
5412 let parsed = parse_add_args(&args).expect("parse should succeed");
5413 assert_eq!(parsed.name, "mylib");
5414 match parsed.spec {
5415 DepSpec::Detailed(d) => {
5416 assert_eq!(d.path.as_deref(), Some("/work/vendored/mylib"));
5417 }
5418 other => panic!("expected DepSpec::Detailed path dep, got {:?}", other),
5419 }
5420 }
5421
5422 #[test]
5423 fn parse_add_args_path_override_flag_wins_over_positional_path() {
5424 let args = vec![
5430 "./aaa".to_string(),
5431 "--path=../bbb".to_string(),
5432 ];
5433 let parsed = parse_add_args(&args).expect("parse should succeed");
5434 match parsed.spec {
5442 DepSpec::Detailed(d) => assert_eq!(d.path.as_deref(), Some("../bbb")),
5443 other => panic!("expected DepSpec::Detailed path dep, got {:?}", other),
5444 }
5445 }
5446}