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::Store;
14use super::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 NAME[@VER] [--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!("Flags:");
208        println!("  --dev            add as a [dev-deps] entry instead of [deps]");
209        println!("  --group=NAME     add to [groups.NAME] (bundler-style)");
210        println!("  --path=DIR       depend on a local checkout (no registry needed)");
211        println!("  --features=A,B   enable feature flags A and B for this dep");
212        println!();
213        println!("Examples:");
214        println!("  stryke add http@1.0");
215        println!("  stryke add test-utils --dev");
216        println!("  stryke add criterion --group=bench");
217        println!("  stryke add mylib --path=../mylib");
218        return 0;
219    }
220    let parsed = match parse_add_args(args) {
221        Ok(p) => p,
222        Err(msg) => {
223            eprintln!("s add: {}", msg);
224            return 1;
225        }
226    };
227
228    let cwd = match std::env::current_dir() {
229        Ok(c) => c,
230        Err(e) => {
231            eprintln!("s add: cwd: {}", e);
232            return 1;
233        }
234    };
235    let root = match find_project_root(&cwd) {
236        Some(r) => r,
237        None => {
238            eprintln!("s add: no stryke.toml found in this directory or any parent");
239            return 1;
240        }
241    };
242
243    let manifest_path = root.join(MANIFEST_FILE);
244    let mut manifest = match Manifest::from_path(&manifest_path) {
245        Ok(m) => m,
246        Err(e) => {
247            eprintln!("s add: {}", e);
248            return 1;
249        }
250    };
251
252    let target_map: &mut IndexMap<String, DepSpec> = match &parsed.kind {
253        AddKind::Runtime => &mut manifest.deps,
254        AddKind::Dev => &mut manifest.dev_deps,
255        AddKind::Group(g) => manifest.groups.entry(g.clone()).or_default(),
256    };
257    target_map.insert(parsed.name.clone(), parsed.spec.clone());
258
259    let body = match manifest.to_toml_string() {
260        Ok(s) => s,
261        Err(e) => {
262            eprintln!("s add: {}", e);
263            return 1;
264        }
265    };
266    if let Err(e) = std::fs::write(&manifest_path, body) {
267        eprintln!("s add: write {}: {}", manifest_path.display(), e);
268        return 1;
269    }
270    eprintln!(
271        "  added {}{} = {}",
272        parsed.name,
273        match &parsed.kind {
274            AddKind::Runtime => "".to_string(),
275            AddKind::Dev => " (dev)".to_string(),
276            AddKind::Group(g) => format!(" (group:{})", g),
277        },
278        format_dep_for_log(&parsed.spec)
279    );
280
281    // Re-run install so the lockfile catches up. Failure here is reported but
282    // doesn't roll back the manifest edit — the user can fix the dep and rerun.
283    cmd_install(&[])
284}
285
286struct AddArgs {
287    name: String,
288    spec: DepSpec,
289    kind: AddKind,
290}
291
292enum AddKind {
293    Runtime,
294    Dev,
295    Group(String),
296}
297
298fn parse_add_args(args: &[String]) -> Result<AddArgs, String> {
299    if args.is_empty() {
300        return Err("usage: s add NAME[@VER] [--dev|--group=NAME] [--path=DIR]".into());
301    }
302    let mut positional: Vec<&String> = Vec::new();
303    let mut kind = AddKind::Runtime;
304    let mut path_override: Option<String> = None;
305    let mut features: Vec<String> = Vec::new();
306    for a in args {
307        match a.as_str() {
308            "--dev" => kind = AddKind::Dev,
309            s if s.starts_with("--group=") => {
310                kind = AddKind::Group(s["--group=".len()..].to_string())
311            }
312            s if s.starts_with("--path=") => path_override = Some(s["--path=".len()..].to_string()),
313            s if s.starts_with("--features=") => {
314                features = s["--features=".len()..]
315                    .split(',')
316                    .map(|s| s.trim().to_string())
317                    .filter(|s| !s.is_empty())
318                    .collect();
319            }
320            s if s.starts_with("--") => {
321                return Err(format!("unknown flag {}", s));
322            }
323            _ => positional.push(a),
324        }
325    }
326    if positional.len() != 1 {
327        return Err(format!(
328            "expected exactly one NAME[@VER] argument, got {}",
329            positional.len()
330        ));
331    }
332    let raw = positional[0].as_str();
333    let (name, version) = match raw.split_once('@') {
334        Some((n, v)) => (n.to_string(), Some(v.to_string())),
335        None => (raw.to_string(), None),
336    };
337    let spec = if let Some(p) = path_override {
338        DepSpec::Detailed(DetailedDep {
339            path: Some(p),
340            version,
341            features,
342            default_features: true,
343            ..DetailedDep::default()
344        })
345    } else if !features.is_empty() {
346        DepSpec::Detailed(DetailedDep {
347            version: Some(version.clone().unwrap_or_else(|| "*".to_string())),
348            features,
349            default_features: true,
350            ..DetailedDep::default()
351        })
352    } else {
353        DepSpec::Version(version.unwrap_or_else(|| "*".to_string()))
354    };
355    Ok(AddArgs { name, spec, kind })
356}
357
358fn format_dep_for_log(spec: &DepSpec) -> String {
359    match spec {
360        DepSpec::Version(v) => format!("\"{}\"", v),
361        DepSpec::Detailed(d) => {
362            let mut bits = Vec::new();
363            if let Some(v) = &d.version {
364                bits.push(format!("version = \"{}\"", v));
365            }
366            if let Some(p) = &d.path {
367                bits.push(format!("path = \"{}\"", p));
368            }
369            if let Some(g) = &d.git {
370                bits.push(format!("git = \"{}\"", g));
371            }
372            if !d.features.is_empty() {
373                bits.push(format!("features = {:?}", d.features));
374            }
375            format!("{{ {} }}", bits.join(", "))
376        }
377        DepSpec::Placeholder => "<placeholder>".into(),
378    }
379}
380
381/// `s remove NAME` — drop the dep from `stryke.toml` and re-run install.
382pub fn cmd_remove(args: &[String]) -> i32 {
383    if args.iter().any(|a| is_help_flag(a)) {
384        println!("usage: stryke remove NAME");
385        println!();
386        println!("Drop NAME from stryke.toml ([deps], [dev-deps], or [groups.*]) and");
387        println!("rerun `s install` so stryke.lock matches.");
388        return 0;
389    }
390    if args.len() != 1 {
391        eprintln!("usage: s remove NAME");
392        return 1;
393    }
394    let name = &args[0];
395    let cwd = match std::env::current_dir() {
396        Ok(c) => c,
397        Err(e) => {
398            eprintln!("s remove: cwd: {}", e);
399            return 1;
400        }
401    };
402    let root = match find_project_root(&cwd) {
403        Some(r) => r,
404        None => {
405            eprintln!("s remove: no stryke.toml found in this directory or any parent");
406            return 1;
407        }
408    };
409    let manifest_path = root.join(MANIFEST_FILE);
410    let mut manifest = match Manifest::from_path(&manifest_path) {
411        Ok(m) => m,
412        Err(e) => {
413            eprintln!("s remove: {}", e);
414            return 1;
415        }
416    };
417    let mut removed = false;
418    if manifest.deps.shift_remove(name).is_some() {
419        removed = true;
420    }
421    if manifest.dev_deps.shift_remove(name).is_some() {
422        removed = true;
423    }
424    for (_g, group_map) in manifest.groups.iter_mut() {
425        if group_map.shift_remove(name).is_some() {
426            removed = true;
427        }
428    }
429    if !removed {
430        eprintln!("s remove: `{}` is not a direct dep", name);
431        return 1;
432    }
433    let body = match manifest.to_toml_string() {
434        Ok(s) => s,
435        Err(e) => {
436            eprintln!("s remove: {}", e);
437            return 1;
438        }
439    };
440    if let Err(e) = std::fs::write(&manifest_path, body) {
441        eprintln!("s remove: write {}: {}", manifest_path.display(), e);
442        return 1;
443    }
444    eprintln!("  removed {}", name);
445    cmd_install(&[])
446}
447
448/// `s install [--offline]` — resolve manifest, install path/workspace deps into
449/// the store, write `stryke.lock`. Registry/git deps return a clear error since
450/// the wire protocol isn't wired yet (RFC phases 7-8).
451pub fn cmd_install(args: &[String]) -> i32 {
452    if args.iter().any(|a| is_help_flag(a)) {
453        println!("usage: stryke install [--offline]");
454        println!();
455        println!("Resolve manifest deps, install path/workspace deps into ~/.stryke/store/,");
456        println!("and write stryke.lock with deterministic ordering + SHA-256 integrity hashes.");
457        println!();
458        println!("Flags:");
459        println!("  --offline    only use cached packages; never fetch from the network");
460        return 0;
461    }
462    let _offline = args.iter().any(|a| a == "--offline");
463
464    let cwd = match std::env::current_dir() {
465        Ok(c) => c,
466        Err(e) => {
467            eprintln!("s install: cwd: {}", e);
468            return 1;
469        }
470    };
471    let root = match find_project_root(&cwd) {
472        Some(r) => r,
473        None => {
474            eprintln!("s install: no stryke.toml found in this directory or any parent");
475            return 1;
476        }
477    };
478
479    let manifest_path = root.join(MANIFEST_FILE);
480    let manifest = match Manifest::from_path(&manifest_path) {
481        Ok(m) => m,
482        Err(e) => {
483            eprintln!("s install: {}", e);
484            return 1;
485        }
486    };
487    if let Err(e) = manifest.validate() {
488        eprintln!("s install: {}", e);
489        return 1;
490    }
491
492    let store = match Store::user_default() {
493        Ok(s) => s,
494        Err(e) => {
495            eprintln!("s install: {}", e);
496            return 1;
497        }
498    };
499
500    let r = Resolver {
501        manifest: &manifest,
502        manifest_dir: &root,
503        store: &store,
504    };
505    let outcome = match r.resolve() {
506        Ok(o) => o,
507        Err(e) => {
508            eprintln!("s install: {}", e);
509            return 1;
510        }
511    };
512
513    if outcome.installed.is_empty() {
514        eprintln!("  no deps to install");
515    } else {
516        for (name, version, _path) in &outcome.installed {
517            eprintln!("  installed {}@{}", name, version);
518        }
519    }
520
521    let mut lf = outcome.lockfile;
522    let body = match lf.to_toml_string() {
523        Ok(s) => s,
524        Err(e) => {
525            eprintln!("s install: {}", e);
526            return 1;
527        }
528    };
529    let lock_path = root.join(LOCKFILE_FILE);
530    if let Err(e) = std::fs::write(&lock_path, body) {
531        eprintln!("s install: write {}: {}", lock_path.display(), e);
532        return 1;
533    }
534    eprintln!(
535        "\x1b[32m✓ wrote {} ({} package{})\x1b[0m",
536        lock_path.display(),
537        lf.packages.len(),
538        if lf.packages.len() == 1 { "" } else { "s" }
539    );
540    0
541}
542
543/// `s tree` — print the resolved dep graph from the lockfile in a human-friendly
544/// format. Roots are the direct deps from `stryke.toml`; transitive deps render
545/// indented underneath. Cycles are not possible (resolver rejects them).
546pub fn cmd_tree(args: &[String]) -> i32 {
547    if args.iter().any(|a| is_help_flag(a)) {
548        println!("usage: stryke tree");
549        println!();
550        println!("Print the resolved dependency graph from stryke.lock as a tree, with the");
551        println!("project at the root and direct + transitive deps underneath.");
552        println!();
553        println!("Run `s install` first to generate stryke.lock.");
554        return 0;
555    }
556    let cwd = match std::env::current_dir() {
557        Ok(c) => c,
558        Err(e) => {
559            eprintln!("s tree: cwd: {}", e);
560            return 1;
561        }
562    };
563    let root = match find_project_root(&cwd) {
564        Some(r) => r,
565        None => {
566            eprintln!("s tree: no stryke.toml found");
567            return 1;
568        }
569    };
570    let manifest = match Manifest::from_path(&root.join(MANIFEST_FILE)) {
571        Ok(m) => m,
572        Err(e) => {
573            eprintln!("s tree: {}", e);
574            return 1;
575        }
576    };
577    let lock_path = root.join(LOCKFILE_FILE);
578    if !lock_path.is_file() {
579        eprintln!("s tree: stryke.lock not found — run `s install` first");
580        return 1;
581    }
582    let lock = match Lockfile::from_path(&lock_path) {
583        Ok(l) => l,
584        Err(e) => {
585            eprintln!("s tree: {}", e);
586            return 1;
587        }
588    };
589
590    let pkg_label = manifest
591        .package
592        .as_ref()
593        .map(|p| format!("{} v{}", p.name, p.version))
594        .unwrap_or_else(|| "(workspace)".to_string());
595    println!("{}", pkg_label);
596
597    let direct_names: Vec<String> = manifest
598        .deps
599        .keys()
600        .chain(manifest.dev_deps.keys())
601        .chain(manifest.groups.values().flat_map(|g| g.keys()))
602        .cloned()
603        .collect();
604
605    for (i, dep_name) in direct_names.iter().enumerate() {
606        let last = i + 1 == direct_names.len();
607        print_tree_entry(&lock, dep_name, "", last);
608    }
609    0
610}
611
612fn print_tree_entry(lock: &Lockfile, name: &str, prefix: &str, last: bool) {
613    let connector = if last { "└── " } else { "├── " };
614    let next_prefix = if last { "    " } else { "│   " };
615    match lock.find(name) {
616        Some(entry) => {
617            println!("{}{}{} v{}", prefix, connector, entry.name, entry.version);
618            for (i, dep_pin) in entry.deps.iter().enumerate() {
619                let dep_name = dep_pin.split_once('@').map(|(n, _)| n).unwrap_or(dep_pin);
620                let last_child = i + 1 == entry.deps.len();
621                print_tree_entry(
622                    lock,
623                    dep_name,
624                    &format!("{}{}", prefix, next_prefix),
625                    last_child,
626                );
627            }
628        }
629        None => {
630            println!("{}{}{} (not in lockfile)", prefix, connector, name);
631        }
632    }
633}
634
635/// `s info NAME` — print the manifest of an installed package from the store.
636/// Reads `~/.stryke/store/NAME@VERSION/stryke.toml` (resolved via current
637/// project's lockfile) and pretty-prints the metadata.
638pub fn cmd_info(args: &[String]) -> i32 {
639    if args.iter().any(|a| is_help_flag(a)) {
640        println!("usage: stryke info NAME");
641        println!();
642        println!("Print the lockfile entry and store path for an installed dep. Shows name,");
643        println!("version, source URL, integrity hash, enabled features, and transitive deps.");
644        println!();
645        println!("Run `s install` first to generate stryke.lock.");
646        return 0;
647    }
648    if args.len() != 1 {
649        eprintln!("usage: s info NAME");
650        return 1;
651    }
652    let name = &args[0];
653    let cwd = match std::env::current_dir() {
654        Ok(c) => c,
655        Err(e) => {
656            eprintln!("s info: cwd: {}", e);
657            return 1;
658        }
659    };
660    let root = match find_project_root(&cwd) {
661        Some(r) => r,
662        None => {
663            eprintln!("s info: no stryke.toml found");
664            return 1;
665        }
666    };
667    let lock_path = root.join(LOCKFILE_FILE);
668    if !lock_path.is_file() {
669        eprintln!("s info: stryke.lock not found — run `s install` first");
670        return 1;
671    }
672    let lock = match Lockfile::from_path(&lock_path) {
673        Ok(l) => l,
674        Err(e) => {
675            eprintln!("s info: {}", e);
676            return 1;
677        }
678    };
679    let entry = match lock.find(name) {
680        Some(e) => e,
681        None => {
682            eprintln!("s info: `{}` is not in stryke.lock", name);
683            return 1;
684        }
685    };
686    let store = match Store::user_default() {
687        Ok(s) => s,
688        Err(e) => {
689            eprintln!("s info: {}", e);
690            return 1;
691        }
692    };
693    let pkg_dir = store.package_dir(&entry.name, &entry.version);
694    println!("name:       {}", entry.name);
695    println!("version:    {}", entry.version);
696    println!("source:     {}", entry.source);
697    println!("integrity:  {}", entry.integrity);
698    if !entry.features.is_empty() {
699        println!("features:   {}", entry.features.join(", "));
700    }
701    if !entry.deps.is_empty() {
702        println!("deps:       {}", entry.deps.join(", "));
703    }
704    println!("store path: {}", pkg_dir.display());
705    let nested_manifest = pkg_dir.join(MANIFEST_FILE);
706    if nested_manifest.is_file() {
707        if let Ok(m) = Manifest::from_path(&nested_manifest) {
708            if let Some(meta) = &m.package {
709                if !meta.description.is_empty() {
710                    println!("description: {}", meta.description);
711                }
712                if !meta.license.is_empty() {
713                    println!("license:    {}", meta.license);
714                }
715                if !meta.repository.is_empty() {
716                    println!("repo:       {}", meta.repository);
717                }
718            }
719        }
720    }
721    0
722}
723
724/// Programmatic entry point: load manifest + lockfile from a project root.
725/// Used by module resolution to translate `use Foo::Bar` → store path.
726pub fn load_project(root: &Path) -> PkgResult<(Manifest, Option<Lockfile>)> {
727    let manifest = Manifest::from_path(&root.join(MANIFEST_FILE))?;
728    let lock_path = root.join(LOCKFILE_FILE);
729    let lockfile = if lock_path.is_file() {
730        Some(Lockfile::from_path(&lock_path)?)
731    } else {
732        None
733    };
734    Ok((manifest, lockfile))
735}
736
737/// Module resolution helper — given a project root and a logical module name
738/// like `"Foo::Bar"`, return the `.stk` source path if any of:
739/// 1. `<root>/lib/Foo/Bar.stk` exists.
740/// 2. The lockfile has an entry for the lower-cased first segment (`foo`),
741///    and `~/.stryke/store/foo@VERSION/lib/Bar.stk` exists.
742///
743/// Returns `Ok(None)` if neither resolved (caller falls through to `@INC`).
744pub fn resolve_module(root: &Path, logical_name: &str) -> PkgResult<Option<PathBuf>> {
745    let segments: Vec<&str> = logical_name.split("::").collect();
746    if segments.is_empty() {
747        return Ok(None);
748    }
749
750    // 1. Project-local `lib/`.
751    let local = root.join("lib").join(segments_to_path(&segments));
752    if local.is_file() {
753        return Ok(Some(local));
754    }
755
756    // 2. Lockfile-driven store lookup. Use the (lower-cased) first segment as
757    //    the package name; remaining segments become the in-package path.
758    let lock_path = root.join(LOCKFILE_FILE);
759    if lock_path.is_file() {
760        let lock = Lockfile::from_path(&lock_path)?;
761        let pkg_name = segments[0].to_lowercase();
762        if let Some(entry) = lock.find(&pkg_name) {
763            let store = Store::user_default()?;
764            let store_pkg = store.package_dir(&entry.name, &entry.version);
765            let nested_path = if segments.len() == 1 {
766                store_pkg.join("lib").join(format!("{}.stk", segments[0]))
767            } else {
768                store_pkg.join("lib").join(segments_to_path(&segments[1..]))
769            };
770            if nested_path.is_file() {
771                return Ok(Some(nested_path));
772            }
773        }
774    }
775    Ok(None)
776}
777
778fn segments_to_path(segments: &[&str]) -> PathBuf {
779    let mut p = PathBuf::new();
780    for (i, seg) in segments.iter().enumerate() {
781        if i + 1 == segments.len() {
782            p.push(format!("{}.stk", seg));
783        } else {
784            p.push(seg);
785        }
786    }
787    p
788}
789
790/// `s clean` — wipe `target/` plus the per-project bytecode cache. Global
791/// `~/.stryke/cache/` is preserved unless `--all` is passed (which also nukes
792/// the store). Path-dep installs in `~/.stryke/store/` are kept by default
793/// because they're trivially regenerated, but the user may not expect a
794/// global wipe just from `s clean`.
795pub fn cmd_clean(args: &[String]) -> i32 {
796    if args.iter().any(|a| is_help_flag(a)) {
797        println!("usage: stryke clean [--all]");
798        println!();
799        println!("Remove the local target/ directory and per-project bytecode cache.");
800        println!();
801        println!("Flags:");
802        println!("  --all    additionally clear ~/.stryke/cache/ and ~/.stryke/store/");
803        return 0;
804    }
805    let want_global = args.iter().any(|a| a == "--all");
806
807    let cwd = match std::env::current_dir() {
808        Ok(c) => c,
809        Err(e) => {
810            eprintln!("s clean: cwd: {}", e);
811            return 1;
812        }
813    };
814    let root = find_project_root(&cwd).unwrap_or(cwd);
815    let mut wiped: Vec<String> = Vec::new();
816    for sub in ["target", ".stryke-cache"] {
817        let d = root.join(sub);
818        if d.exists() {
819            if let Err(e) = std::fs::remove_dir_all(&d) {
820                eprintln!("s clean: remove {}: {}", d.display(), e);
821                return 1;
822            }
823            wiped.push(d.display().to_string());
824        }
825    }
826
827    if want_global {
828        if let Ok(store) = Store::user_default() {
829            for d in [store.cache_dir(), store.store_dir(), store.git_dir()] {
830                if d.exists() {
831                    if let Err(e) = std::fs::remove_dir_all(&d) {
832                        eprintln!("s clean: remove {}: {}", d.display(), e);
833                        return 1;
834                    }
835                    wiped.push(d.display().to_string());
836                }
837            }
838        }
839    }
840
841    if wiped.is_empty() {
842        eprintln!("  nothing to clean");
843    } else {
844        for w in &wiped {
845            eprintln!("  removed {}", w);
846        }
847    }
848    0
849}
850
851/// `s update [NAME]` — re-resolve the manifest and overwrite `stryke.lock`.
852/// Today, with only path/workspace deps wired, this is `s install` with the
853/// existing lockfile thrown out first. When the registry resolver lands, this
854/// is where semver-aware version bumps will live.
855pub fn cmd_update(args: &[String]) -> i32 {
856    if args.iter().any(|a| is_help_flag(a)) {
857        println!("usage: stryke update [NAME]");
858        println!();
859        println!("Re-resolve the dependency graph and rewrite stryke.lock. With registry");
860        println!("deps unwired, this currently re-pins path/workspace dep integrity hashes.");
861        println!();
862        println!("NAME: when given, only that dep is re-resolved (others stay pinned).");
863        return 0;
864    }
865    let cwd = match std::env::current_dir() {
866        Ok(c) => c,
867        Err(e) => {
868            eprintln!("s update: cwd: {}", e);
869            return 1;
870        }
871    };
872    let root = match find_project_root(&cwd) {
873        Some(r) => r,
874        None => {
875            eprintln!("s update: no stryke.toml found");
876            return 1;
877        }
878    };
879    let lock_path = root.join(LOCKFILE_FILE);
880    if lock_path.exists() {
881        if let Err(e) = std::fs::remove_file(&lock_path) {
882            eprintln!("s update: remove {}: {}", lock_path.display(), e);
883            return 1;
884        }
885    }
886    eprintln!("  re-resolving dependency graph");
887    cmd_install(&[])
888}
889
890/// `s outdated` — compare every dep's lockfile pin against its current
891/// upstream state. For path deps that means rehashing the source dir; if the
892/// integrity hash drifted, the dep is "outdated." Registry deps return a
893/// "registry not wired" notice rather than silent green.
894pub fn cmd_outdated(args: &[String]) -> i32 {
895    if args.iter().any(|a| is_help_flag(a)) {
896        println!("usage: stryke outdated");
897        println!();
898        println!("Show deps whose stryke.lock pin no longer matches the upstream state.");
899        println!("Path deps: integrity hash recomputed against the source directory.");
900        println!("Registry deps: not wired in this stryke version.");
901        return 0;
902    }
903    let cwd = match std::env::current_dir() {
904        Ok(c) => c,
905        Err(e) => {
906            eprintln!("s outdated: cwd: {}", e);
907            return 1;
908        }
909    };
910    let root = match find_project_root(&cwd) {
911        Some(r) => r,
912        None => {
913            eprintln!("s outdated: no stryke.toml found");
914            return 1;
915        }
916    };
917    let manifest = match Manifest::from_path(&root.join(MANIFEST_FILE)) {
918        Ok(m) => m,
919        Err(e) => {
920            eprintln!("s outdated: {}", e);
921            return 1;
922        }
923    };
924    let lock_path = root.join(LOCKFILE_FILE);
925    if !lock_path.is_file() {
926        eprintln!("s outdated: stryke.lock not found — run `s install` first");
927        return 1;
928    }
929    let lock = match Lockfile::from_path(&lock_path) {
930        Ok(l) => l,
931        Err(e) => {
932            eprintln!("s outdated: {}", e);
933            return 1;
934        }
935    };
936
937    let mut drifted: Vec<String> = Vec::new();
938    let mut registry_skipped: Vec<String> = Vec::new();
939    for (name, spec) in manifest.deps.iter() {
940        if let Some(p) = spec.path() {
941            let abs = if std::path::Path::new(p).is_absolute() {
942                std::path::PathBuf::from(p)
943            } else {
944                root.join(p)
945            };
946            if let Ok(now) = super::lockfile::integrity_for_directory(&abs) {
947                if let Some(entry) = lock.find(name) {
948                    if entry.integrity != now {
949                        drifted.push(format!(
950                            "  {}@{}  pinned {}  current {}",
951                            name, entry.version, entry.integrity, now
952                        ));
953                    }
954                } else {
955                    drifted.push(format!(
956                        "  {} (path)  not in lockfile — run `s install`",
957                        name
958                    ));
959                }
960            }
961        } else {
962            registry_skipped.push(name.clone());
963        }
964    }
965
966    if drifted.is_empty() && registry_skipped.is_empty() {
967        eprintln!("\x1b[32m✓ all path deps are up to date\x1b[0m");
968        return 0;
969    }
970    if !drifted.is_empty() {
971        eprintln!("path deps with drift (run `s install` to re-pin):");
972        for d in &drifted {
973            eprintln!("{}", d);
974        }
975    }
976    if !registry_skipped.is_empty() {
977        eprintln!(
978            "registry deps skipped — wire protocol not deployed yet ({}): {}",
979            registry_skipped.len(),
980            registry_skipped.join(", ")
981        );
982    }
983    0
984}
985
986/// `s audit` — check the lockfile against a known-vulnerability advisory feed.
987/// The feed itself is not deployed yet; today the command parses the lockfile,
988/// reports the dep count, and emits an honest "no advisories — feed not yet
989/// deployed" message rather than fake "you're safe" output.
990pub fn cmd_audit(args: &[String]) -> i32 {
991    if args.iter().any(|a| is_help_flag(a)) {
992        println!("usage: stryke audit [--fail-on=high|critical]");
993        println!();
994        println!("Check stryke.lock against a vulnerability advisory feed. The feed itself");
995        println!("is not deployed yet — this command currently reports the dep count and");
996        println!("emits an honest 'no advisories' message rather than faking it.");
997        return 0;
998    }
999    let cwd = match std::env::current_dir() {
1000        Ok(c) => c,
1001        Err(e) => {
1002            eprintln!("s audit: cwd: {}", e);
1003            return 1;
1004        }
1005    };
1006    let root = match find_project_root(&cwd) {
1007        Some(r) => r,
1008        None => {
1009            eprintln!("s audit: no stryke.toml found");
1010            return 1;
1011        }
1012    };
1013    let lock_path = root.join(LOCKFILE_FILE);
1014    if !lock_path.is_file() {
1015        eprintln!("s audit: stryke.lock not found — run `s install` first");
1016        return 1;
1017    }
1018    let lock = match Lockfile::from_path(&lock_path) {
1019        Ok(l) => l,
1020        Err(e) => {
1021            eprintln!("s audit: {}", e);
1022            return 1;
1023        }
1024    };
1025    eprintln!(
1026        "  audited {} package{}",
1027        lock.packages.len(),
1028        if lock.packages.len() == 1 { "" } else { "s" }
1029    );
1030    eprintln!("\x1b[33m  advisory feed not yet deployed — no vulnerabilities reported\x1b[0m");
1031    0
1032}
1033
1034/// `s run SCRIPT [ARGS...]` — npm-style task runner. Looks up SCRIPT in the
1035/// `[scripts]` table of the project's `stryke.toml` and executes it via the
1036/// system shell so pipes/redirects work. Any extra ARGS are appended.
1037///
1038/// This is distinct from the existing `stryke run main.stk` semantic: that
1039/// path runs a `.stk` file directly. Script names from `[scripts]` win when
1040/// both are possible (the user's manifest is authoritative).
1041pub fn cmd_run_script(args: &[String]) -> i32 {
1042    if args.iter().any(|a| is_help_flag(a)) {
1043        println!("usage: stryke run SCRIPT [ARGS...]");
1044        println!();
1045        println!("Look up SCRIPT in the [scripts] table of stryke.toml and execute it via");
1046        println!("the system shell. Any ARGS are appended to the script command line.");
1047        println!();
1048        println!("Without [scripts], `stryke run` falls back to running ./main.stk directly.");
1049        return 0;
1050    }
1051    if args.is_empty() {
1052        eprintln!("usage: s run SCRIPT [ARGS...]");
1053        return 1;
1054    }
1055    let script = &args[0];
1056    let cwd = match std::env::current_dir() {
1057        Ok(c) => c,
1058        Err(e) => {
1059            eprintln!("s run: cwd: {}", e);
1060            return 1;
1061        }
1062    };
1063    let root = match find_project_root(&cwd) {
1064        Some(r) => r,
1065        None => {
1066            eprintln!("s run: no stryke.toml found in this directory or any parent");
1067            return 1;
1068        }
1069    };
1070    let manifest = match Manifest::from_path(&root.join(MANIFEST_FILE)) {
1071        Ok(m) => m,
1072        Err(e) => {
1073            eprintln!("s run: {}", e);
1074            return 1;
1075        }
1076    };
1077    let cmd = match manifest.scripts.get(script) {
1078        Some(c) => c.clone(),
1079        None => {
1080            eprintln!("s run: no script `{}` in [scripts]", script);
1081            if !manifest.scripts.is_empty() {
1082                eprintln!(
1083                    "available: {}",
1084                    manifest
1085                        .scripts
1086                        .keys()
1087                        .cloned()
1088                        .collect::<Vec<_>>()
1089                        .join(", ")
1090                );
1091            }
1092            return 1;
1093        }
1094    };
1095    let extra = &args[1..];
1096    let full = if extra.is_empty() {
1097        cmd.clone()
1098    } else {
1099        format!(
1100            "{} {}",
1101            cmd,
1102            extra
1103                .iter()
1104                .map(|a| shell_escape_simple(a))
1105                .collect::<Vec<_>>()
1106                .join(" ")
1107        )
1108    };
1109    eprintln!("  $ {}", full);
1110    let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
1111    let status = std::process::Command::new(&shell)
1112        .arg("-c")
1113        .arg(&full)
1114        .current_dir(&root)
1115        .status();
1116    match status {
1117        Ok(s) => s.code().unwrap_or(1),
1118        Err(e) => {
1119            eprintln!("s run: spawn {}: {}", shell, e);
1120            1
1121        }
1122    }
1123}
1124
1125/// Minimal shell quoting — wrap in single quotes and escape any inner quotes.
1126fn shell_escape_simple(s: &str) -> String {
1127    if !s.contains(' ')
1128        && !s.contains('\'')
1129        && !s.contains('"')
1130        && !s.contains('$')
1131        && !s.contains('`')
1132    {
1133        return s.to_string();
1134    }
1135    let escaped = s.replace('\'', "'\\''");
1136    format!("'{}'", escaped)
1137}
1138
1139/// `s vendor` — copy every dep in `stryke.lock` from the global store into
1140/// `./vendor/<name>@<version>/` so the project builds with `--offline` even
1141/// on a machine without `~/.stryke/store/` populated. Existing `vendor/`
1142/// content is replaced.
1143pub fn cmd_vendor(args: &[String]) -> i32 {
1144    if args.iter().any(|a| is_help_flag(a)) {
1145        println!("usage: stryke vendor");
1146        println!();
1147        println!("Copy every dep in stryke.lock from ~/.stryke/store/ into ./vendor/ so");
1148        println!("the project is offline-buildable. Useful for shipping a tarball that");
1149        println!("builds without registry access.");
1150        return 0;
1151    }
1152    let cwd = match std::env::current_dir() {
1153        Ok(c) => c,
1154        Err(e) => {
1155            eprintln!("s vendor: cwd: {}", e);
1156            return 1;
1157        }
1158    };
1159    let root = match find_project_root(&cwd) {
1160        Some(r) => r,
1161        None => {
1162            eprintln!("s vendor: no stryke.toml found");
1163            return 1;
1164        }
1165    };
1166    let lock_path = root.join(LOCKFILE_FILE);
1167    if !lock_path.is_file() {
1168        eprintln!("s vendor: stryke.lock not found — run `s install` first");
1169        return 1;
1170    }
1171    let lock = match Lockfile::from_path(&lock_path) {
1172        Ok(l) => l,
1173        Err(e) => {
1174            eprintln!("s vendor: {}", e);
1175            return 1;
1176        }
1177    };
1178    let store = match Store::user_default() {
1179        Ok(s) => s,
1180        Err(e) => {
1181            eprintln!("s vendor: {}", e);
1182            return 1;
1183        }
1184    };
1185
1186    let vendor_dir = root.join("vendor");
1187    if vendor_dir.exists() {
1188        if let Err(e) = std::fs::remove_dir_all(&vendor_dir) {
1189            eprintln!("s vendor: clear {}: {}", vendor_dir.display(), e);
1190            return 1;
1191        }
1192    }
1193    if let Err(e) = std::fs::create_dir_all(&vendor_dir) {
1194        eprintln!("s vendor: mkdir {}: {}", vendor_dir.display(), e);
1195        return 1;
1196    }
1197
1198    let mut copied = 0_usize;
1199    for pkg in &lock.packages {
1200        let src = store.package_dir(&pkg.name, &pkg.version);
1201        if !src.is_dir() {
1202            eprintln!(
1203                "s vendor: {}@{} not in store — run `s install` first",
1204                pkg.name, pkg.version
1205            );
1206            return 1;
1207        }
1208        let dst = vendor_dir.join(format!("{}@{}", pkg.name, pkg.version));
1209        if let Err(e) = copy_tree(&src, &dst) {
1210            eprintln!("s vendor: copy {}: {}", src.display(), e);
1211            return 1;
1212        }
1213        copied += 1;
1214    }
1215    eprintln!(
1216        "\x1b[32m✓ vendored {} package{} into {}\x1b[0m",
1217        copied,
1218        if copied == 1 { "" } else { "s" },
1219        vendor_dir.display()
1220    );
1221    0
1222}
1223
1224/// Recursive directory copy used by `s vendor`. Mirrors the resolver's logic
1225/// but lives here to keep it private to vendor (no symlinks-as-symlinks
1226/// requirement — vendor is a flat snapshot).
1227fn copy_tree(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
1228    std::fs::create_dir_all(dst)?;
1229    for entry in std::fs::read_dir(src)? {
1230        let entry = entry?;
1231        let from = entry.path();
1232        let to = dst.join(entry.file_name());
1233        let meta = entry.metadata()?;
1234        if meta.is_dir() {
1235            copy_tree(&from, &to)?;
1236        } else if meta.file_type().is_symlink() {
1237            #[cfg(unix)]
1238            {
1239                let target = std::fs::read_link(&from)?;
1240                std::os::unix::fs::symlink(target, &to)?;
1241            }
1242            #[cfg(not(unix))]
1243            std::fs::copy(&from, &to)?;
1244        } else {
1245            std::fs::copy(&from, &to)?;
1246        }
1247    }
1248    Ok(())
1249}
1250
1251/// `s install -g PATH` — install a path-based package's `[bin]` entries into
1252/// `~/.stryke/bin/` as shebang wrappers. No registry needed — works today for
1253/// any local package with a manifest declaring binaries.
1254pub fn cmd_install_global(args: &[String]) -> i32 {
1255    if args.iter().any(|a| is_help_flag(a)) || args.is_empty() {
1256        println!("usage: stryke install -g PATH");
1257        println!();
1258        println!("Install a local package's [bin] entries into ~/.stryke/bin/ as launcher");
1259        println!("scripts that invoke `stryke <main_stk>`. PATH is the path to a directory");
1260        println!("containing a stryke.toml with a [bin] table.");
1261        return if args.is_empty() { 1 } else { 0 };
1262    }
1263    let pkg_path = std::path::PathBuf::from(&args[0]);
1264    if !pkg_path.is_dir() {
1265        eprintln!("s install -g: {} is not a directory", pkg_path.display());
1266        return 1;
1267    }
1268    let manifest = match Manifest::from_path(&pkg_path.join(MANIFEST_FILE)) {
1269        Ok(m) => m,
1270        Err(e) => {
1271            eprintln!("s install -g: {}", e);
1272            return 1;
1273        }
1274    };
1275    if manifest.bin.is_empty() {
1276        eprintln!("s install -g: package has no [bin] entries");
1277        return 1;
1278    }
1279    let store = match Store::user_default() {
1280        Ok(s) => s,
1281        Err(e) => {
1282            eprintln!("s install -g: {}", e);
1283            return 1;
1284        }
1285    };
1286    if let Err(e) = store.ensure_layout() {
1287        eprintln!("s install -g: {}", e);
1288        return 1;
1289    }
1290
1291    let abs_pkg = match pkg_path.canonicalize() {
1292        Ok(p) => p,
1293        Err(e) => {
1294            eprintln!("s install -g: canonicalize {}: {}", pkg_path.display(), e);
1295            return 1;
1296        }
1297    };
1298
1299    for (bin_name, entry) in &manifest.bin {
1300        let target = abs_pkg.join(entry);
1301        if !target.is_file() {
1302            eprintln!(
1303                "s install -g: bin `{}` -> {} does not exist",
1304                bin_name,
1305                target.display()
1306            );
1307            return 1;
1308        }
1309        let launcher = store.bin_dir().join(bin_name);
1310        if let Err(e) = write_launcher(&launcher, &target) {
1311            eprintln!("s install -g: write {}: {}", launcher.display(), e);
1312            return 1;
1313        }
1314        eprintln!("  installed {} -> {}", launcher.display(), target.display());
1315    }
1316    eprintln!(
1317        "\x1b[32m✓ installed {} bin{} (add {} to PATH)\x1b[0m",
1318        manifest.bin.len(),
1319        if manifest.bin.len() == 1 { "" } else { "s" },
1320        store.bin_dir().display()
1321    );
1322    0
1323}
1324
1325/// Write a `#!/bin/sh` launcher that invokes `stryke <abs_target> "$@"`. We
1326/// don't symlink the .stk source because the launcher needs to call the
1327/// interpreter — symlinking the .stk would make the file appear as the
1328/// "binary" but `./~/.stryke/bin/foo` would just dump perl source.
1329fn write_launcher(
1330    launcher_path: &std::path::Path,
1331    target: &std::path::Path,
1332) -> std::io::Result<()> {
1333    if launcher_path.exists() {
1334        std::fs::remove_file(launcher_path)?;
1335    }
1336    let body = format!(
1337        "#!/bin/sh\nexec stryke {:?} \"$@\"\n",
1338        target.display().to_string()
1339    );
1340    std::fs::write(launcher_path, body)?;
1341    #[cfg(unix)]
1342    {
1343        use std::os::unix::fs::PermissionsExt;
1344        let mut perms = std::fs::metadata(launcher_path)?.permissions();
1345        perms.set_mode(0o755);
1346        std::fs::set_permissions(launcher_path, perms)?;
1347    }
1348    Ok(())
1349}
1350
1351/// `s uninstall -g NAME` — remove a launcher from `~/.stryke/bin/`.
1352pub fn cmd_uninstall_global(args: &[String]) -> i32 {
1353    if args.iter().any(|a| is_help_flag(a)) || args.is_empty() {
1354        println!("usage: stryke uninstall -g NAME");
1355        println!();
1356        println!("Remove the launcher script ~/.stryke/bin/NAME installed by `stryke install -g`.");
1357        return if args.is_empty() { 1 } else { 0 };
1358    }
1359    let store = match Store::user_default() {
1360        Ok(s) => s,
1361        Err(e) => {
1362            eprintln!("s uninstall -g: {}", e);
1363            return 1;
1364        }
1365    };
1366    let target = store.bin_dir().join(&args[0]);
1367    if !target.exists() {
1368        eprintln!("s uninstall -g: {} not installed", args[0]);
1369        return 1;
1370    }
1371    if let Err(e) = std::fs::remove_file(&target) {
1372        eprintln!("s uninstall -g: remove {}: {}", target.display(), e);
1373        return 1;
1374    }
1375    eprintln!("  removed {}", target.display());
1376    0
1377}
1378
1379/// `s list -g` — list every launcher in `~/.stryke/bin/`.
1380pub fn cmd_list_global(args: &[String]) -> i32 {
1381    if args.iter().any(|a| is_help_flag(a)) {
1382        println!("usage: stryke list -g");
1383        println!();
1384        println!("List launchers installed via `stryke install -g` in ~/.stryke/bin/.");
1385        return 0;
1386    }
1387    let store = match Store::user_default() {
1388        Ok(s) => s,
1389        Err(e) => {
1390            eprintln!("s list -g: {}", e);
1391            return 1;
1392        }
1393    };
1394    let bin_dir = store.bin_dir();
1395    if !bin_dir.is_dir() {
1396        eprintln!("  no global tools installed");
1397        return 0;
1398    }
1399    let mut names: Vec<String> = Vec::new();
1400    let entries = match std::fs::read_dir(&bin_dir) {
1401        Ok(e) => e,
1402        Err(e) => {
1403            eprintln!("s list -g: read {}: {}", bin_dir.display(), e);
1404            return 1;
1405        }
1406    };
1407    for entry in entries.flatten() {
1408        if let Some(n) = entry.file_name().to_str() {
1409            names.push(n.to_string());
1410        }
1411    }
1412    names.sort();
1413    if names.is_empty() {
1414        eprintln!("  no global tools installed");
1415    } else {
1416        for n in &names {
1417            println!("{}", n);
1418        }
1419    }
1420    0
1421}
1422
1423/// `s search NAME` — registry-dependent, not deployed yet. Honest stub so the
1424/// CLI shape matches the RFC. When the registry endpoint exists, this hits
1425/// the `/api/v1/index/{name}` path and prints matches.
1426pub fn cmd_search(args: &[String]) -> i32 {
1427    if args.iter().any(|a| is_help_flag(a)) {
1428        println!("usage: stryke search NAME");
1429        println!();
1430        println!("Query the stryke registry for packages matching NAME. The registry");
1431        println!("endpoint is not deployed yet — this command emits a clear diagnostic");
1432        println!("rather than silent failure.");
1433        return 0;
1434    }
1435    if args.is_empty() {
1436        eprintln!("usage: s search NAME");
1437        return 1;
1438    }
1439    eprintln!(
1440        "s search: registry endpoint not deployed yet (RFC §\"Registry Protocol\"). \
1441         Query was `{}`.",
1442        args[0]
1443    );
1444    1
1445}
1446
1447/// `s publish` — registry-dependent stub. When the registry exists, this
1448/// reads the manifest, packages the source as a tarball, computes the
1449/// integrity hash, and POSTs to `/api/v1/packages/{name}/{version}`.
1450pub fn cmd_publish(args: &[String]) -> i32 {
1451    if args.iter().any(|a| is_help_flag(a)) {
1452        println!("usage: stryke publish [--registry=URL] [--dry-run]");
1453        println!();
1454        println!("Package the project as a tarball and push to the stryke registry. The");
1455        println!("registry endpoint is not deployed yet — this command currently performs");
1456        println!("the local pack step (under --dry-run) and stops.");
1457        return 0;
1458    }
1459    let dry_run = args.iter().any(|a| a == "--dry-run");
1460    if !dry_run {
1461        eprintln!(
1462            "s publish: registry endpoint not deployed yet (RFC §\"Registry Protocol\"). \
1463             Pass --dry-run to exercise the local pack step."
1464        );
1465        return 1;
1466    }
1467    // Dry-run: validate the manifest and report what would be sent.
1468    let cwd = match std::env::current_dir() {
1469        Ok(c) => c,
1470        Err(e) => {
1471            eprintln!("s publish: cwd: {}", e);
1472            return 1;
1473        }
1474    };
1475    let root = match find_project_root(&cwd) {
1476        Some(r) => r,
1477        None => {
1478            eprintln!("s publish: no stryke.toml found");
1479            return 1;
1480        }
1481    };
1482    let manifest = match Manifest::from_path(&root.join(MANIFEST_FILE)) {
1483        Ok(m) => m,
1484        Err(e) => {
1485            eprintln!("s publish: {}", e);
1486            return 1;
1487        }
1488    };
1489    if let Err(e) = manifest.validate() {
1490        eprintln!("s publish: {}", e);
1491        return 1;
1492    }
1493    let pkg = match manifest.package.as_ref() {
1494        Some(p) => p,
1495        None => {
1496            eprintln!("s publish: workspace roots can't be published — pick a member");
1497            return 1;
1498        }
1499    };
1500    let integrity = match super::lockfile::integrity_for_directory(&root) {
1501        Ok(s) => s,
1502        Err(e) => {
1503            eprintln!("s publish: hash {}: {}", root.display(), e);
1504            return 1;
1505        }
1506    };
1507    eprintln!("  would publish {} v{}", pkg.name, pkg.version);
1508    eprintln!("  source dir: {}", root.display());
1509    eprintln!("  integrity:  {}", integrity);
1510    eprintln!("  (dry run — no upload performed)");
1511    0
1512}
1513
1514/// `s yank VERSION` — registry-dependent stub. When the registry exists, this
1515/// POSTs to `/api/v1/packages/{name}/{version}/yank`.
1516pub fn cmd_yank(args: &[String]) -> i32 {
1517    if args.iter().any(|a| is_help_flag(a)) {
1518        println!("usage: stryke yank VERSION");
1519        println!();
1520        println!("Mark a published version as do-not-resolve. Registry endpoint not");
1521        println!("deployed yet — this command emits a clear diagnostic rather than");
1522        println!("silent failure. Yanked versions are never deleted (immutable registry).");
1523        return 0;
1524    }
1525    if args.is_empty() {
1526        eprintln!("usage: s yank VERSION");
1527        return 1;
1528    }
1529    eprintln!(
1530        "s yank: registry endpoint not deployed yet (RFC §\"Registry Protocol\"). \
1531         Version was `{}`.",
1532        args[0]
1533    );
1534    1
1535}
1536
1537/// Convenience wrapper: route a top-level `s pkg <subcommand>` invocation. Not
1538/// the primary surface (each subcommand is wired individually in `main.rs`),
1539/// but useful when porting from prototype shells.
1540pub fn dispatch(args: &[String]) -> i32 {
1541    let want_help = args.first().map(|a| is_help_flag(a)).unwrap_or(false);
1542    if args.is_empty() || want_help {
1543        println!("usage: stryke pkg <subcommand> [args]");
1544        println!();
1545        println!("Package-manager subcommand dispatcher. The same handlers are also");
1546        println!("reachable as top-level commands (e.g. `stryke install` ≡ `stryke pkg install`).");
1547        println!();
1548        println!("Subcommands:");
1549        println!("  init [NAME]               scaffold project in cwd");
1550        println!("  new NAME                  scaffold project at ./NAME/");
1551        println!("  install [--offline]       resolve deps + write stryke.lock");
1552        println!("  install -g PATH           install a local package's [bin] entries globally");
1553        println!("  uninstall -g NAME         remove a global launcher");
1554        println!("  list -g                   list global launchers");
1555        println!("  add NAME[@VER] [...]      add a dep to stryke.toml");
1556        println!("  remove NAME               drop a dep from stryke.toml");
1557        println!("  update [NAME]             re-resolve and rewrite stryke.lock");
1558        println!("  outdated                  report deps drifted from their lock pin");
1559        println!("  audit                     check lockfile against advisory feed");
1560        println!("  tree                      print resolved dep graph");
1561        println!("  info NAME                 show lockfile entry for a dep");
1562        println!("  vendor                    snapshot store deps to ./vendor/");
1563        println!("  clean [--all]             wipe target/ (and optionally global caches)");
1564        println!("  search NAME               registry query (registry not deployed)");
1565        println!("  publish [--dry-run]       publish to registry (registry not deployed)");
1566        println!("  yank VERSION              yank a version (registry not deployed)");
1567        println!("  run SCRIPT [ARGS...]      run a [scripts] entry");
1568        println!();
1569        println!("Run `stryke <subcommand> -h` for per-subcommand flags.");
1570        return if args.is_empty() { 1 } else { 0 };
1571    }
1572    match args[0].as_str() {
1573        "init" => cmd_init(args.get(1).map(|s| s.as_str())),
1574        "new" => match args.get(1) {
1575            Some(name) => cmd_new(name),
1576            None => {
1577                eprintln!("usage: s pkg new NAME");
1578                1
1579            }
1580        },
1581        "add" => cmd_add(&args[1..]),
1582        "remove" => cmd_remove(&args[1..]),
1583        "install" => {
1584            // Detect `-g` for global install; falls through to lock-driven install otherwise.
1585            if args.iter().skip(1).any(|a| a == "-g" || a == "--global") {
1586                let filtered: Vec<String> = args[1..]
1587                    .iter()
1588                    .filter(|a| !matches!(a.as_str(), "-g" | "--global"))
1589                    .cloned()
1590                    .collect();
1591                cmd_install_global(&filtered)
1592            } else {
1593                cmd_install(&args[1..])
1594            }
1595        }
1596        "uninstall" => {
1597            if args.iter().skip(1).any(|a| a == "-g" || a == "--global") {
1598                let filtered: Vec<String> = args[1..]
1599                    .iter()
1600                    .filter(|a| !matches!(a.as_str(), "-g" | "--global"))
1601                    .cloned()
1602                    .collect();
1603                cmd_uninstall_global(&filtered)
1604            } else {
1605                eprintln!("s uninstall: pass -g for global tools (no per-project uninstall yet)");
1606                1
1607            }
1608        }
1609        "list" => {
1610            if args.iter().skip(1).any(|a| a == "-g" || a == "--global") {
1611                let filtered: Vec<String> = args[1..]
1612                    .iter()
1613                    .filter(|a| !matches!(a.as_str(), "-g" | "--global"))
1614                    .cloned()
1615                    .collect();
1616                cmd_list_global(&filtered)
1617            } else {
1618                eprintln!("s list: pass -g to list global tools");
1619                1
1620            }
1621        }
1622        "tree" => cmd_tree(&args[1..]),
1623        "info" => cmd_info(&args[1..]),
1624        "update" => cmd_update(&args[1..]),
1625        "outdated" => cmd_outdated(&args[1..]),
1626        "audit" => cmd_audit(&args[1..]),
1627        "vendor" => cmd_vendor(&args[1..]),
1628        "clean" => cmd_clean(&args[1..]),
1629        "search" => cmd_search(&args[1..]),
1630        "publish" => cmd_publish(&args[1..]),
1631        "yank" => cmd_yank(&args[1..]),
1632        "run" => cmd_run_script(&args[1..]),
1633        other => {
1634            eprintln!("s pkg: unknown subcommand `{}`", other);
1635            1
1636        }
1637    }
1638}
1639
1640#[cfg(test)]
1641mod tests {
1642    use super::*;
1643
1644    fn tempdir(tag: &str) -> PathBuf {
1645        let pid = std::process::id();
1646        let nanos = std::time::SystemTime::now()
1647            .duration_since(std::time::UNIX_EPOCH)
1648            .unwrap()
1649            .subsec_nanos();
1650        let p = std::env::temp_dir().join(format!("stryke-cmd-{}-{}-{}", tag, pid, nanos));
1651        let _ = std::fs::remove_dir_all(&p);
1652        std::fs::create_dir_all(&p).unwrap();
1653        p
1654    }
1655
1656    #[test]
1657    fn find_project_root_walks_up() {
1658        let root = tempdir("root");
1659        std::fs::write(
1660            root.join(MANIFEST_FILE),
1661            "[package]\nname=\"x\"\nversion=\"0.1.0\"\n",
1662        )
1663        .unwrap();
1664        let nested = root.join("a/b/c");
1665        std::fs::create_dir_all(&nested).unwrap();
1666        let canonical_root = root.canonicalize().unwrap();
1667        let canonical_nested = nested.canonicalize().unwrap();
1668        let found = find_project_root(&canonical_nested).unwrap();
1669        let canonical_found = found.canonicalize().unwrap();
1670        assert_eq!(canonical_found, canonical_root);
1671    }
1672
1673    #[test]
1674    fn resolve_module_local_lib_hit() {
1675        let root = tempdir("proj");
1676        std::fs::write(
1677            root.join(MANIFEST_FILE),
1678            "[package]\nname=\"x\"\nversion=\"0.1.0\"\n",
1679        )
1680        .unwrap();
1681        std::fs::create_dir_all(root.join("lib/Foo")).unwrap();
1682        std::fs::write(root.join("lib/Foo/Bar.stk"), "# bar").unwrap();
1683        let r = resolve_module(&root, "Foo::Bar").unwrap().unwrap();
1684        assert!(r.ends_with("lib/Foo/Bar.stk"), "got {:?}", r);
1685    }
1686
1687    #[test]
1688    fn resolve_module_falls_back_when_nothing_resolves() {
1689        let root = tempdir("proj");
1690        std::fs::write(
1691            root.join(MANIFEST_FILE),
1692            "[package]\nname=\"x\"\nversion=\"0.1.0\"\n",
1693        )
1694        .unwrap();
1695        let r = resolve_module(&root, "Foo::Bar").unwrap();
1696        assert!(r.is_none());
1697    }
1698}