Skip to main content

stryke/pkg/
commands.rs

1//! `s` subcommand implementations for the package manager.
2//!
3//! Each public function returns an exit code (`i32`) so `main.rs` can wire them
4//! straight into `process::exit(...)`. User-facing diagnostics go to stderr;
5//! machine-readable output (e.g. `s tree`) goes to stdout.
6
7use 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
16/// Filename of the project manifest.
17pub const MANIFEST_FILE: &str = "stryke.toml";
18/// Filename of the project lockfile.
19pub const LOCKFILE_FILE: &str = "stryke.lock";
20
21/// Walk up from `start` to find a directory containing `stryke.toml`. Returns
22/// `None` if no manifest is reachable. Used by every command that operates on a
23/// project (so `s add http` works from any subdirectory).
24pub 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
36/// True if `arg` is a help flag (`-h` or `--help`).
37fn 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
66/// `s new NAME` — scaffold a new project at `./NAME/`.
67pub 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
84/// `s init [NAME]` — scaffold the current directory as a stryke project.
85/// `NAME` defaults to the current directory's basename.
86pub 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
105/// Shared scaffold logic for `s new` and `s init`.
106fn 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
196/// `s add NAME[@VER] [--dev|--group=NAME] [--path=...]` — append a dep to
197/// `stryke.toml` and re-run install. Idempotent on the manifest level: adding
198/// the same dep twice updates the version in place rather than duplicating.
199pub 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    // Re-run install so the lockfile catches up. Failure here is reported but
299    // doesn't roll back the manifest edit — the user can fix the dep and rerun.
300    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    // github.com/OWNER/REPO[@TAG] shorthand → github-release dep. Writes
352    // `github = "OWNER/REPO"` (plus `version = "TAG"` if `@TAG` given),
353    // which routes through Resolver::install_github_dep at install time:
354    // the prebuilt release tarball for the host triple gets downloaded,
355    // SHA-256 verified, and extracted into the store. That's the path
356    // FFI cdylib packages (stryke-arrow, stryke-aws, ...) need — they
357    // can't be reproduced from a source clone without platform libs +
358    // toolchain. For source-only git deps (no [ffi], no published
359    // releases) the user writes `{ git = "..." }` directly in
360    // stryke.toml; the `s add github.com/...` shorthand defaults to the
361    // release path because that's what 99% of public GitHub-hosted
362    // stryke packages actually want. `--path=` still wins (user
363    // explicitly opted into a local copy of the source).
364    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    // Bare path positional → path dep. Matches when the positional
390    // either starts with a path sigil (`/`, `./`, `../`, `~/`) or
391    // resolves to an existing directory. The dep's local name is its
392    // own `[package].name` if a stryke.toml is present, otherwise the
393    // last path component. Explicit `--path=DIR` still wins (it's the
394    // documented override and may legitimately point at a different
395    // directory than the positional argument).
396    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    /// Local manifest key — the repo basename (last path component,
439    /// minus any `.git` suffix). E.g. `stryke-parquet`.
440    name: String,
441    /// `OWNER/REPO` form that lands in the manifest's
442    /// `github = "..."` field. E.g. `MenkeTechnologies/stryke-parquet`.
443    owner_repo: String,
444    /// Optional `@TAG` portion — pins which release to download. Without
445    /// a tag the resolver fetches the latest release at install time.
446    tag: Option<String>,
447}
448
449struct LocalPathArg {
450    name: String,
451    /// The exact string that lands in `stryke.toml`'s `path = "..."`
452    /// field. Relative inputs (`../mylib`, `./vendored`) are preserved
453    /// as-typed so the manifest stays portable across hosts; absolute
454    /// inputs land as absolute. `~/` is expanded so the manifest never
455    /// contains a literal tilde (which the resolver wouldn't expand).
456    path_for_manifest: String,
457}
458
459/// Recognize a bare positional that names a local directory dependency
460/// (`./mylib`, `../foo/bar`, `/abs/path`, `~/projects/mylib`, or any
461/// existing-on-disk directory). The dep's local name comes from the
462/// directory's `stryke.toml`'s `[package].name` when present, otherwise
463/// the last path component. Returns `None` for anything that doesn't
464/// look path-shaped AND isn't an existing directory — those fall
465/// through to the registry / version-spec path so `s add http@1.0`
466/// still works.
467fn parse_local_path_arg(raw: &str) -> Option<LocalPathArg> {
468    // Reject obvious non-paths early: a `@VER` suffix never appears on
469    // path positionals (versions for path deps come from the dep's own
470    // [package].version), and a `:` is a URL marker (file://, http://).
471    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    // Expand `~/` so on-disk lookups + the manifest entry both work.
483    // We resolve `~` only when it's the first path component; embedded
484    // `~` elsewhere is not a tilde (e.g. could be in a vendor dirname).
485    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    // Path-shaped sigils are accepted even when the directory doesn't
498    // yet exist (helps with `s add ../about-to-create`); non-sigil
499    // names only become path deps when they already exist on disk
500    // (avoids treating `serde` as a path because there happens to be
501    // a `./serde` symlink — well, that IS a path, but if there is a
502    // local `./serde` dir the user almost certainly meant it).
503    if !sigil_path && !exists_as_dir {
504        return None;
505    }
506
507    // The name is the package's own [package].name when stryke.toml is
508    // present and parseable; otherwise the last path component.
509    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        // `..` and `.` have no file_name; fall back to the canonicalized
515        // basename in those cases.
516        .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    // For the manifest path string: preserve relative form as typed
538    // (skip tilde expansion when the input was relative — already not
539    // tilde-prefixed), and emit the expanded form for `~/` inputs.
540    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
552/// Recognize `github.com/OWNER/REPO[.git][@TAG]` and `https://github.com/...`
553/// forms. Returns `None` if the raw arg doesn't match (callers fall through
554/// to the registry / version-spec path).
555fn 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    // Must be exactly OWNER/REPO[@TAG] — no extra path components, no
562    // empty owner/repo. `body.splitn(2, '/')` separates owner from the
563    // rest so we can detect a trailing `/` (sub-path) as invalid.
564    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    // The remainder is `REPO[.git][@TAG]`. Sub-paths beyond REPO aren't
571    // a valid git source — refuse them rather than silently truncate.
572    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, // trailing `@` with empty tag is malformed
578        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
626/// `s remove NAME` — drop the dep from `stryke.toml` and re-run install.
627pub 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
693/// `s install [--offline]` — resolve manifest, install path/workspace deps into
694/// the store, write `stryke.lock`. Registry/git deps return a clear error since
695/// the wire protocol isn't wired yet (RFC phases 7-8).
696pub 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    // Project installs deliberately do NOT touch ~/.stryke/installed.toml.
787    // The global index belongs to `s install -g` exclusively; project deps
788    // are pinned by the project's own stryke.lock. Standalone scripts still
789    // resolve `use Foo` against the store via the highest-semver scan.
790    0
791}
792
793/// `s tree` — print the resolved dep graph from the lockfile in a human-friendly
794/// format. Roots are the direct deps from `stryke.toml`; transitive deps render
795/// indented underneath. Cycles are not possible (resolver rejects them).
796pub 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
885/// `s info NAME` — print the manifest of an installed package from the store.
886/// Reads `~/.stryke/store/NAME@VERSION/stryke.toml` (resolved via current
887/// project's lockfile) and pretty-prints the metadata.
888pub 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
974/// Programmatic entry point: load manifest + lockfile from a project root.
975/// Used by module resolution to translate `use Foo::Bar` → store path.
976pub 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
987/// Module resolution helper — given a project root and a logical module name
988/// like `"Foo::Bar"`, return the `.stk` source path if any of:
989/// 1. `<root>/lib/Foo/Bar.stk` exists.
990/// 2. The lockfile has an entry for the lower-cased first segment (`foo`),
991///    and `~/.stryke/store/foo@VERSION/lib/Bar.stk` exists.
992/// 3. The global `installed.toml` has an entry and the store dir exists.
993///
994/// `pin_version` (Perl-style `use Module VERSION` from the parser) is the
995/// override: when `Some(v)`, the resolver looks up `<store>/<name>@<v>/`
996/// directly and skips both the lockfile and the global index. An explicit
997/// pin **always wins** — inside or outside a project. When `None`, the
998/// lockfile-then-index precedence applies.
999///
1000/// Returns `Ok(None)` if nothing resolved (caller falls through to `@INC`).
1001/// Returns `Err(...)` only when a pin can't be honored — either a use-site
1002/// `use Module VERSION` whose store dir is missing, or a lockfile entry
1003/// whose pinned version is missing from the store. In both cases falling
1004/// through silently would let a *different* version satisfy the load,
1005/// which is the "stryke use must respect package version" bug. Surfacing
1006/// the miss forces the user to run `s install` instead.
1007pub 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    // The `.stk` wrapper is the source-of-truth — if it exists at any tier,
1018    // resolution succeeds. The companion cdylib (when `[ffi]` is declared) is
1019    // best-effort: a missing or unloadable cdylib does NOT abort resolution
1020    // here, because that would silently send a real local hit down to @INC
1021    // (the caller in `vm_helper::try_resolve_via_lockfile` does
1022    // `resolve_module(...).unwrap_or_default()`). The first FFI call into an
1023    // unregistered export will surface the load failure at the actual call
1024    // site with a clear message, which is the right layer to report it.
1025
1026    // 1. Project-local `lib/`. The use-site pin doesn't apply here —
1027    //    local lib/ is whatever's in the project's tree; pinning it to
1028    //    a different version is meaningless. Local hits always win
1029    //    (live edits should never be shadowed by a store version).
1030    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    // 1b. Flat-layout namespace bridge. Stryke-* packages ship
1037    //     `lib/<Sub>.stk` declaring `package <Ns>::<Sub>` with no
1038    //     `lib/<Ns>/` subdir. Bridge fires when segments[0] (case-
1039    //     insensitive) matches any of:
1040    //       * `[ffi].namespace` — FFI packages (stryke-arrow, stryke-aws).
1041    //       * `[package].name` — packages literally named after the
1042    //         namespace (rare; future-proofing).
1043    //       * `[package].name` minus the `stryke-` prefix — every
1044    //         stryke-* pure-stryke package (stryke-utils: name =
1045    //         "stryke-utils", umbrella = `Utils`).
1046    //     Mirrors the global-store branch (#3) and the
1047    //     `canonical_store_names_for_namespace` 3-arm logic so `s test`
1048    //     inside the package dir finds its own siblings without
1049    //     needing `s install -g .` after every edit.
1050    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    // 2a. Use-site pin (`use Module VERSION`) — direct store lookup,
1077    //     no lockfile / index consult. Inside or outside a project.
1078    //     Standalone scripts that want a specific version write
1079    //     `use Module 1.2` and land directly on `<store>/<name>@1.2/`.
1080    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    // 2b. Lockfile-driven store lookup. Use the (lower-cased) first segment as
1110    //     the package name; remaining segments become the in-package path. Fall
1111    //     back to `stryke-<name>` so `use GUI` bridges to a `stryke-gui` entry
1112    //     even when the lockfile dep key is the prefixed canonical name.
1113    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            // Lockfiles often record the dep alias (`name = "postgres"`)
1123            // while the store directory uses the canonical package name
1124            // (`stryke-postgres@<ver>/`). Try the alias path first, then
1125            // fall back to the prefixed canonical form so deps installed
1126            // before the alias-vs-canonical convention shake-out still
1127            // resolve. Same path the analyzer uses (kept in sync).
1128            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            // Lockfile pinned the version but the store has no
1140            // extraction at that version. Refuse to fall through to
1141            // the global index — silently picking a different version
1142            // would violate the lockfile pin. Surface the mismatch so
1143            // the user runs `s install`.
1144            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    // 3. Outside-project / unpinned global lookup. The script runs
1153    //    outside any stryke project (no `stryke.toml` ancestor) or the
1154    //    project's lockfile doesn't pin this package. The contract here
1155    //    is "latest installed version" — scan the store for every
1156    //    `<canonical>@*/` extraction matching the namespace and pick
1157    //    the highest semver. This is robust against InstalledIndex
1158    //    drift (`upsert` records last-installed, not highest-installed),
1159    //    and matches the LSP's version-completion behavior.
1160    //
1161    // Canonical name discovery: same 3-arm logic as the LSP —
1162    // `[package].name` direct match, `[ffi].namespace` match, then
1163    // `stryke-<lowername>` prefix fallback. Each arm contributes
1164    // names to scan for; we union them and let the scan pick the
1165    // highest version across all matches.
1166    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
1184/// Resolve a lowercased `use Foo` first segment to every store-name it
1185/// might be extracted under. Three sources, unioned + deduped:
1186///
1187/// 1. `<ns>` itself — the bare namespace (e.g. `gui`).
1188/// 2. `stryke-<ns>` — the prefixed canonical name used by every
1189///    `stryke-*` ecosystem package.
1190/// 3. `installed.toml` entries whose `[package].name` matches the
1191///    namespace OR whose `[ffi].namespace` matches — bridges
1192///    `use GUI` to a `stryke-gui` extraction regardless of how the
1193///    name was recorded.
1194///
1195/// Returns the union so `scan_store_for_highest_version` can pick the
1196/// best version across all of them — important when both `gui@0.9` and
1197/// `stryke-gui@1.2` exist on disk from different install paths.
1198pub(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
1219/// Walk `<store>/` and pick the highest-version extraction whose
1220/// directory name is `<candidate>@<version>/` for any candidate in
1221/// `names`. Returns `(canonical_name, version_string)` for the
1222/// winner. Versions are compared as dotted-integer tuples so
1223/// `2.0` > `1.99` and `1.0` < `1.0.1` order the way humans expect.
1224///
1225/// Non-semver-looking versions (e.g. `dev`, `0.1-pre`) sort as zero
1226/// in the numeric comparison and lose to anything with real numbers,
1227/// matching the LSP's completion ranking.
1228pub(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/// Sort key for ranking `<store>/<name>@<version>/` directories by
1256/// "newest wins" semantics. Total-ordered so the scan is deterministic
1257/// regardless of filesystem iteration order.
1258///
1259/// Ordering rules, in priority:
1260/// 1. Numeric tuple from the dotted prefix: `2.0 > 1.99`, `0.10 > 0.3`
1261///    (numeric compare, not lexicographic).
1262/// 2. Release > pre-release. A version with a `-suffix` (e.g.
1263///    `1.0.0-rc1`) ranks below the same numeric prefix without one
1264///    (`1.0.0`). Mirrors semver §11 — without this rule, `1.0.0` and
1265///    `1.0.0-rc1` mapped to the same key and the scan returned
1266///    whichever directory the filesystem happened to yield first.
1267/// 3. Within pre-releases, alphabetic prefix of the suffix
1268///    (`alpha < beta < rc`), then trailing number (`rc10 > rc2`).
1269///    Not a full semver §11 implementation — the resolver only needs
1270///    a stable total order; the package manager owns the strict
1271///    semver layer.
1272#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1273pub(crate) struct VersionRank {
1274    /// Dotted numeric prefix as a Vec<u64>. `1.0.0` → `[1, 0, 0]`.
1275    nums: Vec<u64>,
1276    /// `1` for a release (no `-suffix`), `0` for any pre-release. Comes
1277    /// before the suffix fields so release beats every pre-release with
1278    /// the same numeric prefix.
1279    is_release: u8,
1280    /// Alphabetic head of the pre-release suffix (`rc` from `rc10`).
1281    /// Empty for releases; lexicographic compare gives the conventional
1282    /// `alpha < beta < rc` ordering.
1283    suffix_alpha: String,
1284    /// Trailing number in the pre-release suffix (`10` from `rc10`).
1285    /// Zero when the suffix has no number (`beta` → `0`).
1286    suffix_num: u64,
1287}
1288
1289impl VersionRank {
1290    pub(crate) fn parse(ver: &str) -> Self {
1291        // Split numeric prefix from pre-release suffix at the first `-`.
1292        // Build-metadata (`+sha…`) is not part of precedence per semver
1293        // §10; strip it if present.
1294        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        // Split suffix into alpha-head + trailing number. `rc10` →
1308        // ("rc", 10); `beta` → ("beta", 0). Anything past the trailing
1309        // digits is discarded — the resolver doesn't need to round-trip
1310        // semver, only rank deterministically.
1311        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
1332/// Build the list of plausible `<store>/<name>@<version>/` paths for a
1333/// resolver entry. Tries the entry name as-is first, then `stryke-<name>`
1334/// when the entry isn't already prefixed. Covers the legacy split where
1335/// lockfile/installed.toml entries record the dep alias (`postgres`) but
1336/// the store directory is the canonical package name (`stryke-postgres`).
1337fn 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
1350/// Side-load the package's `[ffi]` cdylib (if declared) into the FFI registry.
1351/// Idempotent across repeat `use NAMESPACE` calls within one process —
1352/// [`crate::rust_ffi::load_cdylib`] short-circuits when the lib was already
1353/// loaded. Returns `Ok(())` for packages without an `[ffi]` section.
1354fn 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    // Search order:
1373    //   1. lib/<lib_filename>    — production layout (release tarball drop).
1374    //   2. target/release/...    — dev layout after `cargo build --release`.
1375    //   3. target/debug/...      — dev layout after `cargo build`.
1376    // Letting `target/` also satisfy the lookup lets contributors iterate on
1377    // a stryke-* package without re-running `s pkg install -g .` after every
1378    // edit. Production installs always hit (1) because the release tarball
1379    // ships the cdylib at lib/.
1380    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
1414/// `s clean` — wipe `target/` plus the per-project bytecode cache. Global
1415/// `~/.stryke/cache/` is preserved unless `--all` is passed (which also nukes
1416/// the store). Path-dep installs in `~/.stryke/store/` are kept by default
1417/// because they're trivially regenerated, but the user may not expect a
1418/// global wipe just from `s clean`.
1419pub 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
1475/// `s update [NAME]` — re-resolve the manifest and overwrite `stryke.lock`.
1476/// Today, with only path/workspace deps wired, this is `s install` with the
1477/// existing lockfile thrown out first. When the registry resolver lands, this
1478/// is where semver-aware version bumps will live.
1479pub 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
1514/// `s upgrade [NAME]` — in a project: move deps to their latest upstream
1515/// versions, rewriting stryke.toml pins, then re-resolve via `s update`.
1516///
1517/// This is deliberately stronger than `s update`: `update` re-resolves
1518/// within the manifest's existing constraints; `upgrade` moves the
1519/// constraints themselves. Per dep kind:
1520///   - `{ github = "OWNER/REPO", version = "vX" }` — fetch the latest
1521///     release tag, rewrite `version` when it moved.
1522///   - `{ git = "https://github.com/..." , tag = "vX" }` — same, rewriting `tag`.
1523///   - unpinned github deps already float to latest on every re-resolve.
1524///   - path/workspace deps track their source dir — nothing to bump.
1525///   - registry deps can't be bumped until the registry endpoint is wired.
1526pub 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    // Bump pins across [deps], [dev-deps], and every [groups.*] table.
1567    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    // Re-resolve regardless: unpinned github deps float to latest here, and
1605    // path-dep integrity hashes re-pin against the current source dirs.
1606    cmd_update(&[])
1607}
1608
1609/// Bump one dep's manifest pin to the latest upstream release. Returns
1610/// `Ok(true)` when the pin was rewritten, `Ok(false)` when there was nothing
1611/// to bump (already latest, unpinned, path/workspace, or registry dep).
1612fn bump_dep_pin(name: &str, spec: &mut DepSpec) -> Result<bool, String> {
1613    let DepSpec::Detailed(d) = spec else {
1614        // Bare `name = "1.0"` registry shorthand — registry not wired.
1615        eprintln!("  - {} registry dep — skipped (registry not wired)", name);
1616        return Ok(false);
1617    };
1618    // `{ github = "OWNER/REPO" }` — bump the `version` pin.
1619    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    // `{ git = "https://github.com/...", tag = "vX" }` — bump the `tag` pin.
1635    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        // Path/workspace deps track their source — re-resolve handles them.
1655        return Ok(false);
1656    }
1657    eprintln!("  - {} registry dep — skipped (registry not wired)", name);
1658    Ok(false)
1659}
1660
1661/// `s outdated` — compare every dep's lockfile pin against its current
1662/// upstream state. For path deps that means rehashing the source dir; if the
1663/// integrity hash drifted, the dep is "outdated." Registry deps return a
1664/// "registry not wired" notice rather than silent green.
1665pub 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
1757/// `s audit` — check the lockfile against a known-vulnerability advisory feed.
1758/// The feed itself is not deployed yet; today the command parses the lockfile,
1759/// reports the dep count, and emits an honest "no advisories — feed not yet
1760/// deployed" message rather than fake "you're safe" output.
1761pub 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
1805/// `s run SCRIPT [ARGS...]` — npm-style task runner. Looks up SCRIPT in the
1806/// `[scripts]` table of the project's `stryke.toml` and executes it via the
1807/// system shell so pipes/redirects work. Any extra ARGS are appended.
1808///
1809/// This is distinct from the existing `stryke run main.stk` semantic: that
1810/// path runs a `.stk` file directly. Script names from `[scripts]` win when
1811/// both are possible (the user's manifest is authoritative).
1812pub 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
1896/// Minimal shell quoting — wrap in single quotes and escape any inner quotes.
1897fn 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
1910/// `s vendor` — copy every dep in `stryke.lock` from the global store into
1911/// `./vendor/<name>@<version>/` so the project builds with `--offline` even
1912/// on a machine without `~/.stryke/store/` populated. Existing `vendor/`
1913/// content is replaced.
1914pub 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
1995/// Recursive directory copy used by `s vendor`. Mirrors the resolver's logic
1996/// but lives here to keep it private to vendor (no symlinks-as-symlinks
1997/// requirement — vendor is a flat snapshot).
1998fn 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
2022/// `s install -g PATH` — install a path-based package's `[bin]` entries into
2023/// `~/.stryke/bin/` as shebang wrappers. No registry needed — works today for
2024/// any local package with a manifest declaring binaries.
2025pub 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
2092/// Shared tail of every global install/upgrade: write the `[bin]` launchers,
2093/// pin the package in `~/.stryke/installed.toml`, and print the success line.
2094/// Called by `cmd_install_global` and per-package by `cmd_upgrade_global`.
2095fn finalize_global_install(
2096    store: &Store,
2097    manifest: &Manifest,
2098    store_pkg_dir: &Path,
2099    source: &str,
2100) -> Result<(), String> {
2101    // Launchers from [bin]. FFI-only packages may have empty [bin] — fine,
2102    // they're invoked via `use <namespace>` not via a CLI launcher.
2103    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    // Pin the install in ~/.stryke/installed.toml so standalone scripts
2119    // (no project dir) can resolve `use <namespace>` to this store entry
2120    // — that's the resolution path resolve_module's third arm walks.
2121    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        // Warn loud when replacing a different pinned version so the user
2125        // notices the old store dir is now an orphan. Same-version reinstall
2126        // is silent (idempotent re-runs are normal).
2127        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
2171/// After an upgrade reinstall, the fresh pin lands under the manifest's
2172/// `[package].name`. When the index entry that triggered the upgrade was
2173/// recorded under a DIFFERENT name (the package was renamed upstream, e.g.
2174/// `polars` → `stryke-polars`), that old entry would survive forever and
2175/// re-trigger an "upgrade" on every run — drop it.
2176fn 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
2196/// A pinned package's provenance, parsed back out of the `source` string
2197/// `s install -g` wrote into `~/.stryke/installed.toml`.
2198enum PinnedSource {
2199    /// `github:owner/repo@tag` — upgradable by asking the GitHub releases API
2200    /// for the latest tag.
2201    GitHub { owner: String, repo: String },
2202    /// `path+file:///abs/dir` — upgradable by re-reading the source dir's
2203    /// manifest and re-copying when its version moved.
2204    Path(PathBuf),
2205    /// `local-install:name@version` — legacy row written by project installs
2206    /// back when they pinned into the global index. Project deps belong to
2207    /// their project's stryke.lock; these rows are pruned on upgrade.
2208    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
2228/// `v0.2.0` and `0.2.0` are the same version — release tags conventionally
2229/// carry the `v`, manifest `[package].version` never does.
2230fn same_version(a: &str, b: &str) -> bool {
2231    a.trim_start_matches('v') == b.trim_start_matches('v')
2232}
2233
2234/// Newest modification time anywhere under `root` (recursive). `None` when
2235/// the path doesn't exist or can't be stat'd. Directories contribute their
2236/// own mtime too so a freshly emptied subtree still registers.
2237fn 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
2256/// True when a `path+file://` source's canonical install inputs are newer
2257/// than the copy currently in the store (or the store copy is missing).
2258/// Lets `s upgrade -g` re-sync a live local checkout whose working tree
2259/// changed without a version bump. The store's files are stamped with the
2260/// install time by `std::fs::copy`, so the newest store mtime is a faithful
2261/// "last installed at" threshold; any source edit afterward reads as newer.
2262/// Inputs mirror what `install_global_from_path` actually stages: the
2263/// manifest, the `lib/` and `bin/` subtrees, and a dev-built cdylib under
2264/// `target/release/`.
2265fn 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; // not in the store — must (re)install
2269    };
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
2288/// `s upgrade -g [NAME]` — re-pin every globally installed package at its
2289/// latest upstream version. GitHub pins poll the releases API and reinstall
2290/// when the tag moved; path pins re-read the source dir's manifest and
2291/// re-copy when its version moved OR its working tree changed since the last
2292/// install (a live local checkout rarely bumps its version per edit). Legacy
2293/// `local-install:` rows (written by
2294/// project installs before they stopped touching the global index) are
2295/// pruned — project deps belong to their project's stryke.lock, not here.
2296/// With NAME, only that package.
2297pub 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    // Snapshot the entries up front: finalize_global_install rewrites
2328    // installed.toml after each successful upgrade.
2329    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                // A package installed from a local `stryke.toml` still tracks
2399                // the GitHub repo its manifest declares. Poll that repo: when
2400                // its latest release is strictly newer than BOTH the installed
2401                // version and the local working-tree version, the published
2402                // release wins — upgrade from GitHub and re-pin to `github:`.
2403                // (If the local checkout is ahead, the dev is working past the
2404                // last release; the local re-sync below keeps the path pin.)
2405                // A failed poll is non-fatal: fall through to the local sync.
2406                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                // A `path+file://` source is a live local checkout. Version
2440                // bumps are the explicit upgrade signal, but during active
2441                // development the working tree changes far more often than the
2442                // version string does — so when the version matches, re-copy
2443                // anyway if any canonical install input (stryke.toml, lib/,
2444                // bin/, or a dev-built target/release cdylib) is newer than
2445                // what's currently in the store. Without this, edits that
2446                // don't bump the version (e.g. an updated `lib/*.stk`) are
2447                // silently never picked up by `s upgrade -g`.
2448                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                // Written by project installs before they stopped touching
2479                // the global index. Project deps live in their project's
2480                // stryke.lock; the store entry stays (standalone `use Foo`
2481                // still resolves via the highest-semver store scan).
2482                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    // Total = packages the global index actually manages after pruning.
2514    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
2537/// Parsed argument to `s install -g SPEC`.
2538enum InstallSpec {
2539    /// Local directory containing `stryke.toml`. Source-copied into the store.
2540    Path(PathBuf),
2541    /// GitHub release. Prebuilt tarball downloaded per host triple.
2542    GitHub {
2543        owner: String,
2544        repo: String,
2545        /// Tag to install (`v0.2.0`, `0.2.0`, etc.). `None` means latest.
2546        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
2582/// Split `gh:foo/bar@v1.0` into `("gh:foo/bar", Some("v1.0"))`. The `@` only
2583/// counts as a version separator when followed by a digit or `v` so paths
2584/// containing `@` (e.g. `~/projects/team@2023/pkg`) are not misparsed.
2585fn 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
2597/// Parse a manifest `repository` URL into a GitHub `(owner, repo)` pair, or
2598/// `None` when it isn't a recognizable GitHub URL. Used by `s upgrade -g` so a
2599/// package globally installed from a local `stryke.toml` still tracks the
2600/// releases of the GitHub repo its manifest points at. Handles the https/http,
2601/// scheme-less, `gh:`, and `git@`/`ssh://` SSH forms; `parse_gh_owner_repo`
2602/// strips any trailing `/` or `.git`.
2603fn 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    // For `[ffi]` packages, the cdylib must end up at `lib/lib<name>.<ext>`
2654    // in the store (production layout — what GH-shipped tarballs always
2655    // contain). Source trees may or may not have it pre-built:
2656    //
2657    //   * `lib/lib<name>.<ext>`           — already in production layout; copied via the
2658    //                                       filtered subtree pass below.
2659    //   * `target/release/lib<name>.<ext>` — dev built; staged into dst/lib/ after copy.
2660    //   * neither                         — run `cargo build --release` here, then stage.
2661    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    // Stage the freshly-built (or target/-located) cdylib into dst/lib/ when
2668    // the source's lib/ didn't already have it. Mirrors the GH layout so
2669    // try_load_ffi_for finds the lib at candidate (1) — no fallback to
2670    // target/ needed once installed.
2671    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
2693/// For `[ffi]` packages, locate the cdylib in the source tree or build it
2694/// via `cargo build --release` if absent. Returns `Some((built_path,
2695/// dll_filename))` when a build was needed or the lib lives outside
2696/// `lib/` — the caller stages the file into `dst/lib/`. Returns `None`
2697/// when the package has no `[ffi]` section or when `lib/<filename>` is
2698/// already present in source (the filtered subtree copy handles it).
2699pub(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        // Already in production layout — install_path_dep's subtree pass
2719        // copies it as part of `lib/`.
2720        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    // Nothing built yet. Need a Cargo.toml at the source root to drive the
2729    // build — error clearly when absent so the user knows they're missing
2730    // the cdylib crate, not the toolchain.
2731    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    // Stage under cache/ then rename into store/ atomically so a failure
2818    // mid-extract doesn't leave a half-populated store entry.
2819    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
2863/// GET `https://api.github.com/repos/{owner}/{repo}/releases/latest` and pull
2864/// the `tag_name` field. Returns the tag verbatim — `v0.2.0`, `0.2.0`, etc.
2865fn 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
2884/// Host triple used to pick which release asset to download. Uses
2885/// `STRYKE_TARGET` when set (escape hatch for musl, cross builds, exotic
2886/// architectures). The default mapping covers the four triples that every
2887/// stryke-* package's release CI must publish.
2888fn 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
2911/// The release tarball convention is `tar czf - <pkgdir>/`, which produces an
2912/// archive with one top-level directory. Some packages publish a flat tarball
2913/// (manifest at the root) instead; we accept both shapes.
2914fn 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
2939/// Write a `#!/bin/sh` launcher that invokes `stryke <abs_target> "$@"`. We
2940/// don't symlink the .stk source because the launcher needs to call the
2941/// interpreter — symlinking the .stk would make the file appear as the
2942/// "binary" but `./~/.stryke/bin/foo` would just dump perl source.
2943fn 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
2965/// `s uninstall -g NAME` — remove a package installed by `s install -g`.
2966/// Drops the global pin in `~/.stryke/installed.toml` (so `use NAME` from a
2967/// standalone script no longer resolves), removes every launcher in
2968/// `~/.stryke/bin/` declared by the package's manifest, and leaves the
2969/// store entry in place (re-install hits the same path without re-fetching).
2970pub 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    // Pull the pin first so we can find every [bin] launcher to remove.
2990    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                                // Per-launcher write to `removed_anything` was
3011                                // redundant — line 1830 unconditionally sets
3012                                // it to true once we reach this arm (the
3013                                // package IS in the index, so the unpin
3014                                // counts as success even if zero launchers
3015                                // existed). Keep the visible eprintln, drop
3016                                // the dead assignment that clippy flags.
3017                                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        // No pin — fall back to single-launcher removal for packages that
3041        // pre-date the installed-index machinery.
3042        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
3060/// `s use -g NAME@VERSION` — switch which version of an already-installed
3061/// package the global pin points at, without re-fetching. Errors loud when
3062/// the requested store dir doesn't exist — the user must `install -g` first.
3063///
3064/// Lets users keep multiple side-by-side versions on disk and flip between
3065/// them for standalone scripts. Inside a project the lockfile still wins
3066/// over the global pin, so this has no effect on project-scoped resolution.
3067pub 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
3154/// `s gc -g [--dry-run]` — remove every `~/.stryke/store/<name>@<version>/`
3155/// not currently pinned in `installed.toml`. Returns the total bytes freed.
3156/// `--dry-run` reports what would be removed without touching disk.
3157///
3158/// Project lockfiles can pin versions that the global index does not — but
3159/// only the active project's lockfile is reachable from here (no way to find
3160/// every checkout on disk). Run this from outside any project, or accept
3161/// that store entries needed by a project lockfile may be deleted and have
3162/// to be re-fetched by `s install` next time.
3163pub 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
3238/// Sum every regular file under `path` recursively. Used by `s gc -g` so its
3239/// output names how much disk each removal frees. Symlinks are not followed
3240/// (matches the install side's `copy_dir` symlink-preserves behavior).
3241fn 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
3260/// `s list -g` — list every launcher in `~/.stryke/bin/`.
3261pub 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    // ── packages: every pinned entry ──
3289    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    // ── launchers: files in bin/ (FFI-only packages may have none) ──
3299    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    // ── orphans: store/<name>@<ver>/ not matching any pin ──
3321    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
3333/// Return every `~/.stryke/store/<name>@<version>/` whose `(name, version)` is
3334/// not the currently-pinned entry in [`InstalledIndex`]. A store dir whose
3335/// name isn't in the index at all is also an orphan. Used by `s pkg list -g`
3336/// and `s pkg gc -g`.
3337fn 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, // malformed entry — skip rather than crash
3352        };
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
3363/// `s search NAME` — registry-dependent, not deployed yet. Honest stub so the
3364/// CLI shape matches the RFC. When the registry endpoint exists, this hits
3365/// the `/api/v1/index/{name}` path and prints matches.
3366pub 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
3387/// `s publish` — registry-dependent stub. When the registry exists, this
3388/// reads the manifest, packages the source as a tarball, computes the
3389/// integrity hash, and POSTs to `/api/v1/packages/{name}/{version}`.
3390pub 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    // Dry-run: validate the manifest and report what would be sent.
3408    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
3454/// `s yank VERSION` — registry-dependent stub. When the registry exists, this
3455/// POSTs to `/api/v1/packages/{name}/{version}/yank`.
3456pub 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
3477/// Convenience wrapper: route a top-level `s pkg <subcommand>` invocation. Not
3478/// the primary surface (each subcommand is wired individually in `main.rs`),
3479/// but useful when porting from prototype shells.
3480pub 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        // `s i` is a convenience alias for `s install` (matches `cargo install`'s
3534        // implicit shorthand). All flag behavior (`-g`, `--offline`, etc.) is
3535        // identical — we just dispatch to the same handler.
3536        "install" | "i" => {
3537            // Detect `-g` for global install; falls through to lock-driven install otherwise.
3538            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    /// Tests that mutate `STRYKE_HOME` are serialized through this mutex —
3638    /// the env var is process-global, so parallel test execution would race
3639    /// (one test's clearing wipes another test's setting mid-run). Grab the
3640    /// guard at the top of any STRYKE_HOME-touching test; drop it via scope.
3641    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        // Every string shape a `let source = ...` writer produces must
3658        // round-trip through PinnedSource::parse.
3659        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        // Every branch that must NOT hit the GitHub API returns Ok(false).
3681        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        // Unpinned github dep floats on re-resolve — no API call, no rewrite.
3691        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        // Non-github git URL has no releases API to poll.
3698        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        // Non-GitHub and empty inputs yield None.
3742        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        // Empty index + explicit NAME → exit 1 (typo protection).
3756        assert_eq!(cmd_upgrade_global(&["nosuchpkg".to_string()]), 1);
3757        // Empty index, no NAME → nothing to do, exit 0.
3758        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        // The legacy project-install row is pruned, not upgraded or counted.
3774        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        // No [ffi] table — must succeed silently.
3792        try_load_ffi_for(&d).unwrap();
3793    }
3794
3795    #[test]
3796    fn try_load_ffi_for_missing_manifest_is_ok() {
3797        // Bare store entry (e.g. mid-install partial state) — no manifest.
3798        // Must not panic; FFI load is a no-op for plain dirs.
3799        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        // Error must mention the platform-specific filename and point at the
3815        // search locations so the user knows what to install / build.
3816        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        // Pin gui@0.2.0 and aws@0.1.0. Expect gui@0.1.0 and gui@0.3.0 as orphans.
3844        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        // A store dir whose name isn't in the index at all counts as an orphan.
3861        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        // Only 0.1.0 exists. Asking for 0.2.0 must fail without writing.
3900        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        // Empty index → both store dirs are orphans, but --dry-run must not delete.
3961        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        // Verify the explicit-store API end-to-end at the commands layer
3974        // (the version of this test in store.rs covers the load/save path
3975        // directly; this one verifies upsert + find as the resolver uses
3976        // them). Both avoid STRYKE_HOME so parallel runs don't race.
3977        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        // `@scope/pkg` style — not a version, do not split.
4061        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    /// Flat-layout namespace bridge — every `stryke-*` connector ships
4141    /// `lib/<Sub>.stk` declaring `package <Ns>::<Sub>` with no
4142    /// `lib/<Ns>/` subdirectory (stryke-arrow, stryke-aws, stryke-gcp,
4143    /// …). `use <Ns>::<Sub>` from inside the project must chase
4144    /// `lib/<Sub>.stk` via the `[ffi].namespace` match, otherwise
4145    /// `stryke t` (and every reverse-dependency build) blows up with
4146    /// `Can't locate <Ns>/<Sub>.pm in @INC`.
4147    #[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    /// Namespace bridge must be case-insensitive — `[ffi].namespace =
4165    /// "AWS"` accepts `use aws::S3` and vice-versa, matching how the
4166    /// global-store branch lower-cases segments[0] for canonical_names.
4167    #[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    /// Namespace bridge must NOT shortcut when the first segment doesn't
4183    /// match `[ffi].namespace` — otherwise `use Other::Foo` would
4184    /// silently bind to this project's `lib/Foo.stk`.
4185    #[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    /// Pure-stryke packages with no `[ffi]` table still need the bridge.
4201    /// `stryke-utils` ships `lib/String.stk` declaring `package
4202    /// Utils::String` and a top-level `[package].name = "stryke-utils"`
4203    /// with no `[ffi]` block — `use Utils::String` from inside the
4204    /// project must chase `lib/String.stk` via the `stryke-<ns>` name
4205    /// arm. Without this, the umbrella `use Utils` triggers a chain of
4206    /// `Can't locate Utils/<Sub>.pm in @INC` errors and every assertion
4207    /// past `Utils::version()` fails.
4208    #[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    /// Pure-stryke bridge stays scoped: an unrelated `use Foo::Bar` from
4225    /// inside `stryke-utils` must NOT bind to `lib/Bar.stk`.
4226    #[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    /// Legacy install (or first-install on a fresh machine) where the index
4253    /// entry has the prefixed name `stryke-gui` but no namespace recorded.
4254    /// `use GUI` must still resolve via the `stryke-<lowername>` fallback —
4255    /// otherwise older machines get a Perl @INC fallback and the user sees
4256    /// `Can't locate GUI.pm`.
4257    #[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        // Empty namespace simulates a legacy entry written before the
4271        // install path populated the namespace field.
4272        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    /// "stryke use must respect package version" — use-site `use Foo 1.0`
4284    /// outside any project must land on `<store>/foo@1.0/` directly,
4285    /// even when the global installed.toml records a different version.
4286    #[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        // Two versions on disk; installed.toml pins the newer one.
4295        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    /// Pin requested but the version isn't in the store — resolver
4321    /// MUST refuse to fall through to a different version. The whole
4322    /// point of the pin is "this version or nothing".
4323    #[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        // Only 2.0 in store; user pins 1.0 which doesn't exist.
4332        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    /// Outside-project resolution must pick the HIGHEST version of the
4351    /// package in the store — not whatever installed.toml last
4352    /// upserted, and not the lexicographically-largest dir name.
4353    /// `2.0` beats `1.99` (numeric tuple compare).
4354    #[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        // No installed.toml entry — proves the scan path is what
4372        // picks the version, not the index.
4373
4374        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    /// Highest-version scan must respect canonical-name mapping —
4387    /// `use GUI` should pick the highest `stryke-gui@*/` even though
4388    /// the bare `gui@*/` form doesn't exist on disk.
4389    #[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    /// Pre-release identifier loses to release (semver §11). `1.0.0-rc1`
4420    /// must rank below `1.0.0`, not tie with it. Without this rule the
4421    /// scan returns whichever extraction the filesystem yields first
4422    /// — flaky cross-platform, flaky across `s install` orderings.
4423    #[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        // Both extracted side-by-side. Release MUST win.
4432        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    /// Within pre-releases, the semver §11 ordering is `alpha < beta
4455    /// < rc`. Stryke's resolver scan doesn't need full semver — it
4456    /// only needs *deterministic* ranking — but the resolver should
4457    /// at least keep the higher-numbered pre-release suffix on top
4458    /// of the lower one (`rc2 > rc1`).
4459    #[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        // Only pre-releases on disk — release isn't out yet.
4468        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    /// Lockfile pins a version whose store dir is missing. Resolver MUST
4491    /// error rather than silently fall through to the global index —
4492    /// that fall-through was the version-disrespect bug.
4493    #[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        // Global has foo@2.0 (latest install). Project lockfile pins
4502        // foo@1.0 but the store doesn't have that extracted.
4503        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    // ── Library-resolution tests (L1, L4) ─────────────────────────────
4542    //
4543    // These prove the three IDE-facing surfaces — linter, completion,
4544    // hover — all see installed-package subs through the same 3-arm
4545    // resolver (`static_analysis::resolve_require_path_from_file`,
4546    // `lsp::installed_package_completions`, `lsp::hover_markdown_for_word`).
4547    // STRYKE_HOME-mutating tests grab STRYKE_HOME_MUTEX so parallel
4548    // execution doesn't race on the process-global env var.
4549
4550    /// Create a fake installed package at `<STRYKE_HOME>/store/<name>@<ver>/`
4551    /// with a single `lib/<Name>.stk` declaring the supplied sub names.
4552    /// Each sub gets a `## doc-line` directly above it so doc extraction
4553    /// has content to surface. Returns the store package path.
4554    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    /// L1a — resolve_require_path_from_file finds an installed-package
4582    /// .stk file via the global pin when no local file resolves.
4583    #[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        // Script file lives in a directory with no stryke.toml — arm 1 + 2
4597        // miss, arm 3 (installed.toml) should fire.
4598        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    /// L1b — project-local `lib/GUI.stk` shadows the globally-pinned
4615    /// store entry (matches `resolve_module` arm 1 → wins over arm 3).
4616    #[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        // Project with its own lib/GUI.stk that takes priority.
4630        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        // Resolved path must be the project-local file, NOT the store.
4650        assert_eq!(
4651            resolved.as_deref().and_then(|p| p.canonicalize().ok()),
4652            local_gui.canonicalize().ok()
4653        );
4654    }
4655
4656    /// L1c — project lockfile (arm 2) wins over global pin (arm 3) when
4657    /// both name the same package but at different versions.
4658    #[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        // Global pin says 0.2.0, project lockfile pins 0.1.0.
4670        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        // Must be the 0.1.0 store entry (lockfile wins), NOT the 0.2.0
4698        // global pin.
4699        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    /// L4a — installed_package_completions surfaces every entry in
4706    /// installed.toml when no filter is supplied.
4707    #[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    /// L4b — case-insensitive prefix match: typing `gu` or `Gu` both
4731    /// match the `GUI` namespace.
4732    #[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    /// L4c — when no stryke.toml `[ffi].namespace` is declared, the
4758    /// completion falls back to scanning the first `lib/*.stk` for a
4759    /// `package X` line.
4760    #[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        // Manifest WITHOUT an [ffi] section — namespace must come from
4771        // the `package MyPkg` line in the lib file.
4772        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    /// L4d — `use Gui|` and `require Foo|` both register as use-context
4794    /// (drives `use<TAB>` → installed-package mode).
4795    #[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    /// L4e — non-use lines or use-with-arguments (e.g. `use overload '+'`)
4808    /// must NOT trigger use-context. Otherwise sigil completion or
4809    /// string-arg edits get hijacked.
4810    #[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    /// L1+L4 regression — `use GUI; GUI::mouse_pos()` from a script
4835    /// outside any project must NOT fire UndefinedSubroutine under the
4836    /// IDE LSP's strict-vars path. The bug shipped through v0.16.32 +
4837    /// v0.16.33: `resolve_require_path_from_file` was correct but
4838    /// `StmtKind::Use` in `collect_declarations_stmt` only called
4839    /// `declare_sub(module)` — it never chased the resolved file, so
4840    /// the GUI:: sub declarations never landed in the analyzer's
4841    /// scope. Added `follow_require(module)` in v0.16.34 to mirror
4842    /// what `require Foo::Bar` does.
4843    #[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        // Script sits in a non-project tempdir so neither arm 1
4863        // (project lib/) nor arm 2 (lockfile) can hit. Arm 3
4864        // (installed.toml) is the only candidate.
4865        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        // Mirror what the IDE LSP's compute_diagnostics does:
4873        // analyze_program_with_strict(program, path, true).
4874        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    /// L5 — when the analyzer chases into a cdylib package's `lib/X.stk`
4891    /// wrapper, the wrapper's body calls FFI exports (`gui__mouse_pos`,
4892    /// `gui__keyboard_keys`, etc.) that have no .stk-side declaration —
4893    /// they're `#[no_mangle] extern "C" fn`s in the cdylib registered
4894    /// at runtime by `rust_ffi::load_cdylib`. v0.16.34 read the sibling
4895    /// stryke.toml's `[ffi].exports` and pre-declares each export as a
4896    /// known sub before walking the file's statements. Without this
4897    /// pre-pass every `gui__*` bareword call would fire as
4898    /// UndefinedSubroutine inside the IDE LSP's strict-vars path.
4899    #[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        // Hand-craft a fake gui package with a .stk wrapper whose body
4909        // calls FFI exports. fake_installed_pkg's default wrapper only
4910        // declares `fn`s — overwrite it with one that calls `gui__*`
4911        // names from inside each sub body so the analyzer has to walk
4912        // those call sites with strict mode on.
4913        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        // User's script — outside any project, so arm 3 fires.
4935        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    /// L6 — end-to-end IDE scenario: hover on the package name `GUI`
4960    /// from a user script that does `use GUI`, with the package's
4961    /// `lib/GUI.stk` shipping the conventional rustdoc layout (file-level
4962    /// `##` block, blank separator, `package GUI`). v0.16.36's walker
4963    /// fix made the blank separator survivable; this test pins that the
4964    /// cross-file chase + walker combo surfaces the file-header docs to
4965    /// the IDE hover card.
4966    #[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        // Hand-craft the package — the default fake_installed_pkg helper
4976        // doesn't lay out file-level docs, but the real stryke-gui 0.2.2
4977        // ships this exact shape (## header, blank, `package GUI`).
4978        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        // User script — outside any project, just `use GUI`. The IDE
5003        // would hover over the `GUI` token on this line.
5004        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    /// L7 — end-to-end IDE hover on a library SUB (`GUI::mouse_pos`)
5031    /// whose `##` docstring sits a blank line above the `fn` decl —
5032    /// the conventional layout the user's stryke-gui 0.2.2 ships with.
5033    /// Pre-v0.16.36 this returned the header alone; post-fix it surfaces
5034    /// the docstring too.
5035    #[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        // Conventional layout: blank line between docstring and fn.
5054        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    /// L2 — hover_markdown_for_word chases `use GUI` into the store
5089    /// file and surfaces the `## …` doc block declared above the sub.
5090    #[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        // Script lives in a non-project dir.
5104        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        // Doc block from fake_installed_pkg is "Mock docstring for GUI::mouse_pos."
5118        assert!(
5119            md.contains("Mock docstring for GUI::mouse_pos"),
5120            "hover did not include the chased doc: {}",
5121            md
5122        );
5123    }
5124
5125    // === parse_github_shorthand =========================================
5126    //
5127    // `s add github.com/OWNER/REPO[@TAG]` must produce a git dep, not a
5128    // registry dep (which would fail `s install` with the RFC-phases-7-8
5129    // unimplemented error). These tests pin both directions: shorthands
5130    // that should be recognized, and forms that look github-ish but
5131    // shouldn't quietly convert (sub-paths, http://, malformed tags).
5132
5133    #[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        // Random crate name — must fall through to registry path.
5180        assert!(parse_github_shorthand("serde").is_none());
5181        assert!(parse_github_shorthand("http@1.0").is_none());
5182        // GitLab / other hosts — not in scope for this shorthand.
5183        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        // `github.com/owner/repo/subdir` is not a valid git source URL;
5190        // the parser must NOT silently truncate to `repo`.
5191        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        // Trailing `@` with no tag is malformed (user-typo of `@<TAB>`).
5206        assert!(parse_github_shorthand("github.com/owner/repo@").is_none());
5207    }
5208
5209    // === cmd_add integration ============================================
5210
5211    #[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                // No `version` → resolver fetches the latest release.
5223                assert!(d.version.is_none());
5224                // Important: must NOT write a `git` URL. That would route
5225                // through install_git_dep (source clone), which is wrong
5226                // for FFI packages — they need the release tarball.
5227                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                // `@TAG` lands in the `version` field — that's what
5247                // install_global_from_github uses to construct the
5248                // release-tarball URL.
5249                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        // Explicit `--path=` must still take precedence — the user opted
5259        // into a local checkout even though the SPEC looks github-shaped.
5260        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        // Backstop the existing behavior for plain registry deps.
5278        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    // === parse_local_path_arg ===========================================
5288    //
5289    // `s add ./mylib` and friends should write a path dep, not a registry
5290    // dep. These pin both the sigil-driven cases (where the directory
5291    // doesn't have to exist — useful for "about to create") and the
5292    // exists-on-disk auto-detection (where bare names like `mylib`
5293    // become path deps when there's a `./mylib` next to the cwd).
5294
5295    #[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        // Absolute sigil is enough to disambiguate — the directory
5314        // doesn't need to exist yet at the moment of `s add`.
5315        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        // When the targeted directory has a stryke.toml with
5336        // [package].name, that name takes precedence over the
5337        // directory basename. Common when the dep dir is `crates/foo`
5338        // but the package is named `foo-lib`.
5339        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        // No sigil, but the directory exists — auto-detect as path.
5357        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        // Plain crate-style names without a sigil and without an
5374        // existing on-disk dir must NOT be parsed as paths (the user
5375        // wants the registry path → `DepSpec::Version("*")`).
5376        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        // `http@1.0` is a registry version, not a path.
5383        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        // URLs contain `:` — those go to git/github paths, not local.
5390        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        // User passes BOTH a path-shaped positional AND --path= — the
5425        // explicit flag wins, but the positional's NAME is still
5426        // extracted from itself (since it was being treated as a
5427        // local path sigil even before the override). With both
5428        // path-shapes, the flag's directory is the source of truth.
5429        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        // `./aaa` is detected as a github-shorthand miss → falls through
5435        // to the standard path/features/version branches below. With
5436        // --path=../bbb set, the branch becomes "path dep with version
5437        // = None, path = ../bbb". The name comes from the @-split of
5438        // `./aaa`, which yields `./aaa` as the manifest key.
5439        // (The path-positional auto-detection above is gated by
5440        // `path_override.is_none()`, so --path takes priority.)
5441        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}