Skip to main content

sdkt_core/
package.rs

1//! Local package-manager foundation for the Soroban DevKit (sdkt), .
2//!
3//! This module lays the groundwork for a future package registry **without**
4//! any network or remote-registry functionality. It supports:
5//!
6//! * a package manifest (`[package]` with `name`/`version`/`description`),
7//! * local path-only dependencies (`[dependencies]` with `path = "..."`),
8//! * offline validation of package metadata and the local dependency graph.
9//!
10//! No git, HTTP, or registry resolution is performed. The dependency-graph
11//! cycle/duplicate/self checks reuse the same topological-sort algorithm that
12//! `project::resolve_deploy_order` uses, extracted into [`topo_sort`] so the
13//! two resolvers share one implementation.
14
15use crate::config::{DevKitConfig, PackageConfig};
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18use std::fmt;
19use std::path::{Path, PathBuf};
20
21/// Errors raised while validating a local package manifest/dependency graph.
22#[derive(Debug, PartialEq)]
23pub enum PackageError {
24    /// The `[package]` section is absent, but a manifest operation needs it.
25    MissingPackage,
26    /// `[package]` has no `name`.
27    MissingName,
28    /// `[package]` has no `version`.
29    MissingVersion,
30    /// `[package].version` is not a valid semver (`MAJOR.MINOR.PATCH[...]`).
31    InvalidVersion(String),
32    /// A `[dependencies]` entry has no `path` (the only supported source).
33    MissingPath(String),
34    /// A `[dependencies]` entry references an unknown/non-path key (e.g. `git`).
35    UnsupportedSource(String),
36    /// A dependency name is also a declared package (self-dependency).
37    SelfDependency(String),
38    /// The dependency graph contains a cycle.
39    CircularDependency(String),
40    /// The dependency graph declares the same dependency more than once.
41    DuplicateDependency(String),
42    /// A dependency's `path` does not exist on disk.
43    PathNotFound(String),
44    /// A dependency declares both a `path` and a `git` source.
45    MixedSources(String),
46    /// A `git` dependency is missing its URL.
47    MissingGitUrl(String),
48    /// A `git` dependency URL is not a valid URL.
49    InvalidGitUrl(String),
50    /// A `git` dependency URL uses an unsupported scheme (only `https`/`http`/`git`/`ssh`).
51    UnsupportedUrlScheme(String),
52    /// A `git` dependency specifies more than one of `branch`/`tag`/`rev`.
53    MultipleGitRefs(String),
54    /// A `git` dependency specifies none of `branch`/`tag`/`rev`.
55    MissingGitRef(String),
56    /// A `git` dependency's `branch`/`tag`/`rev` value is empty.
57    EmptyGitRef(String),
58    /// Generic error carrying a free-form message (used by packaging I/O).
59    Other(String),
60}
61
62impl fmt::Display for PackageError {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        match self {
65            PackageError::MissingPackage => write!(f, "missing [package] section in manifest"),
66            PackageError::MissingName => write!(f, "[package] is missing a `name`"),
67            PackageError::MissingVersion => write!(f, "[package] is missing a `version`"),
68            PackageError::InvalidVersion(v) => write!(
69                f,
70                "invalid [package] version '{}' (expected MAJOR.MINOR.PATCH)",
71                v
72            ),
73            PackageError::MissingPath(name) => {
74                write!(f, "dependency '{}' is missing a `path`", name)
75            }
76            PackageError::UnsupportedSource(name) => write!(
77                f,
78                "dependency '{}' uses an unsupported source (only local `path` is allowed)",
79                name
80            ),
81            PackageError::SelfDependency(name) => write!(f, "package '{}' depends on itself", name),
82            PackageError::CircularDependency(name) => {
83                write!(f, "circular local dependency detected involving '{}'", name)
84            }
85            PackageError::DuplicateDependency(name) => {
86                write!(
87                    f,
88                    "package '{}' declares the same dependency more than once",
89                    name
90                )
91            }
92            PackageError::PathNotFound(path) => {
93                write!(f, "dependency path does not exist: {}", path)
94            }
95            PackageError::MixedSources(name) => {
96                write!(f, "dependency '{}' declares both `path` and `git`", name)
97            }
98            PackageError::MissingGitUrl(name) => {
99                write!(f, "git dependency '{}' is missing a `git` URL", name)
100            }
101            PackageError::InvalidGitUrl(name) => {
102                write!(f, "git dependency '{}' has an invalid URL", name)
103            }
104            PackageError::UnsupportedUrlScheme(url) => {
105                write!(
106                    f,
107                    "git dependency URL uses an unsupported scheme: {} (only https/http/git/ssh allowed)",
108                    url
109                )
110            }
111            PackageError::MultipleGitRefs(name) => {
112                write!(
113                    f,
114                    "git dependency '{}' specifies more than one of `branch`/`tag`/`rev`",
115                    name
116                )
117            }
118            PackageError::MissingGitRef(name) => {
119                write!(
120                    f,
121                    "git dependency '{}' must specify exactly one of `branch`/`tag`/`rev`",
122                    name
123                )
124            }
125            PackageError::EmptyGitRef(name) => {
126                write!(
127                    f,
128                    "git dependency '{}' has an empty `branch`/`tag`/`rev`",
129                    name
130                )
131            }
132            PackageError::Other(msg) => write!(f, "{}", msg),
133        }
134    }
135}
136
137impl std::error::Error for PackageError {}
138
139/// Validate the `[package]` manifest metadata in isolation.
140///
141/// Checks that `name` and `version` are present and that `version` is a valid
142/// `MAJOR.MINOR.PATCH` (optionally with a pre-release/build suffix, per
143/// semver.org). Returns the parsed `PackageConfig` on success, or a clear
144/// [`PackageError`] otherwise. Does not touch the filesystem or network.
145pub fn validate_package(config: &DevKitConfig) -> Result<PackageConfig, PackageError> {
146    let pkg = config
147        .package
148        .as_ref()
149        .ok_or(PackageError::MissingPackage)?;
150
151    let name = pkg.name.as_ref().ok_or(PackageError::MissingName)?;
152    if name.trim().is_empty() {
153        return Err(PackageError::MissingName);
154    }
155
156    let version = pkg.version.as_ref().ok_or(PackageError::MissingVersion)?;
157    if version.trim().is_empty() {
158        return Err(PackageError::MissingVersion);
159    }
160    validate_version_format(version)?;
161
162    Ok(pkg.clone())
163}
164
165/// Resolve the best available dependency version that satisfies a semver
166/// constraint, given a list of `(tag, commit)` pairs (already fetched from the
167/// remote via `git ls-remote --tags`). Pure: no I/O, no allocation beyond the
168/// returned `Option`.
169///
170/// Selection rule (): among the remote tags whose names parse as valid
171/// semver, return the one whose version *satisfies* `constraint` and is the
172/// highest by semver ordering. Tags that are not valid semver (e.g. `latest`,
173/// `main`) are ignored. If no tag satisfies the constraint, returns `None`.
174///
175/// This is the single source of truth for constraint matching; `plan_updates`
176/// in `crate::sync` calls it rather than re-implementing comparator logic.
177pub fn best_version_match(tags: &[(String, String)], constraint: &str) -> Option<(String, String)> {
178    use semver::{Version, VersionReq};
179
180    let req = VersionReq::parse(constraint).ok()?;
181    let mut best: Option<(Version, (String, String))> = None;
182    for (tag, commit) in tags {
183        // Strip a leading "v" so `v1.2.0` parses as `1.2.0`.
184        let bare = tag.strip_prefix('v').unwrap_or(tag);
185        let ver = match Version::parse(bare) {
186            Ok(v) => v,
187            Err(_) => continue,
188        };
189        if !req.matches(&ver) {
190            continue;
191        }
192        match &best {
193            Some((bv, _)) if *bv >= ver => {}
194            _ => best = Some((ver, (tag.clone(), commit.clone()))),
195        }
196    }
197    best.map(|(_, pair)| pair)
198}
199
200/// Validate a single `[package]` version string.
201///
202/// Accepts `MAJOR.MINOR.PATCH` with an optional pre-release (`-x.y`) and/or
203/// build metadata (`+abc`), matching the common semver shape. Rejects empty
204/// strings, missing components, non-numeric components, and negative numbers.
205pub fn validate_version_format(version: &str) -> Result<(), PackageError> {
206    let v = version.trim();
207    if v.is_empty() {
208        return Err(PackageError::InvalidVersion(version.to_string()));
209    }
210    let core = v.split(['+', '-']).next().unwrap_or(v);
211    let parts: Vec<&str> = core.split('.').collect();
212    if parts.len() != 3 {
213        return Err(PackageError::InvalidVersion(version.to_string()));
214    }
215    for p in parts {
216        if p.is_empty() || !p.chars().all(|c| c.is_ascii_digit()) {
217            return Err(PackageError::InvalidVersion(version.to_string()));
218        }
219        // Reject leading zeros (semver: "01" is invalid, "0" is fine).
220        if p.len() > 1 && p.starts_with('0') {
221            return Err(PackageError::InvalidVersion(version.to_string()));
222        }
223    }
224    Ok(())
225}
226
227/// Generic topological sort (Kahn's algorithm) over a string-keyed graph.
228///
229/// This is the single shared cycle-detection core, reused by both the contract
230/// deploy-order resolver and the local package dependency resolver so the two
231/// never diverge. `graph` maps each node to the set of nodes it depends on.
232///
233/// Returns `Ok(order)` with a deterministic ordering, or `Err(cycle_node)`
234/// naming one node that could not be resolved (i.e. part of a cycle).
235pub fn topo_sort(graph: &HashMap<String, Vec<String>>) -> Result<Vec<String>, String> {
236    let mut in_degree: HashMap<String, usize> = HashMap::new();
237    let mut adj_list: HashMap<String, Vec<String>> = HashMap::new();
238
239    for node in graph.keys() {
240        in_degree.entry(node.clone()).or_insert(0);
241        adj_list.entry(node.clone()).or_default();
242    }
243
244    for (node, deps) in graph {
245        for dep in deps {
246            adj_list.entry(dep.clone()).or_default().push(node.clone());
247            *in_degree.entry(node.clone()).or_insert(0) += 1;
248        }
249    }
250
251    let mut queue: Vec<String> = in_degree
252        .iter()
253        .filter(|(_, &d)| d == 0)
254        .map(|(n, _)| n.clone())
255        .collect();
256    queue.sort();
257
258    let mut ordered = Vec::new();
259    while let Some(current) = queue.first().cloned() {
260        queue.remove(0);
261        ordered.push(current.clone());
262        if let Some(neighbors) = adj_list.get(&current) {
263            for neighbor in neighbors {
264                if let Some(d) = in_degree.get_mut(neighbor) {
265                    *d -= 1;
266                    if *d == 0 {
267                        queue.push(neighbor.clone());
268                    }
269                }
270            }
271        }
272        queue.sort();
273    }
274
275    if ordered.len() != graph.len() {
276        for (node, &degree) in &in_degree {
277            if degree > 0 {
278                return Err(node.clone());
279            }
280        }
281        return Err("Unknown".to_string());
282    }
283
284    Ok(ordered)
285}
286
287/// Validate the local `[dependencies]` graph.
288///
289/// Checks, in order, without performing any network I/O:
290/// * exactly one source per dependency — a `path` OR a `git` URL (not both),
291/// * for `path` deps: the path is non-empty and resolves to an existing dir,
292/// * for `git` deps: a URL is present and a valid, supported-scheme URL;
293///   exactly one of `branch`/`tag`/`rev` (none empty),
294/// * no self-dependency (a dependency key equal to the package name),
295/// * no duplicate dependency name,
296/// * the dependency graph is acyclic (reuses [`topo_sort`]).
297///
298/// `base_dir` is the manifest's directory used to resolve relative `path`
299/// values (in production this is `.`, the cwd sdkt runs from). Git deps are
300/// only validated for syntax/shape here; acquisition happens in the fetch
301/// layer ([`crate::fetch`]).
302pub fn validate_dependencies(base_dir: &Path, config: &DevKitConfig) -> Result<(), PackageError> {
303    let pkg_name = config
304        .package
305        .as_ref()
306        .and_then(|p| p.name.clone())
307        .unwrap_or_default();
308
309    let mut graph: HashMap<String, Vec<String>> = HashMap::new();
310
311    for (name, dep) in &config.dependencies {
312        if name == &pkg_name {
313            return Err(PackageError::SelfDependency(name.clone()));
314        }
315
316        if graph.contains_key(name) {
317            return Err(PackageError::DuplicateDependency(name.clone()));
318        }
319
320        let is_path = dep.path.is_some();
321        let is_git = dep.git.is_some();
322
323        match (is_path, is_git) {
324            (true, true) => return Err(PackageError::MixedSources(name.clone())),
325            (true, false) => {
326                let path = dep.path.as_ref().unwrap();
327                if path.trim().is_empty() {
328                    return Err(PackageError::MissingPath(name.clone()));
329                }
330                let full = base_dir.join(path);
331                if !full.exists() {
332                    return Err(PackageError::PathNotFound(full.display().to_string()));
333                }
334            }
335            (false, true) => {
336                let url = dep.git.as_ref().unwrap();
337                if url.trim().is_empty() {
338                    return Err(PackageError::MissingGitUrl(name.clone()));
339                }
340                validate_git_url(url)?;
341
342                // Exactly one of branch/tag/rev, none empty — UNLESS a
343                // `version` constraint is declared (), in which case the
344                // constraint resolves against remote tags and no explicit ref
345                // is required. An explicit ref always takes precedence and
346                // makes the `version` constraint inert.
347                let has_ref = dep.branch.is_some() || dep.tag.is_some() || dep.rev.is_some();
348                if !has_ref {
349                    if let Some(ver) = &dep.version {
350                        // A `version` constraint (), e.g. ">=1.0, <2". It is
351                        // a semver *requirement* (VersionReq), NOT a fixed
352                        // MAJOR.MINOR.PATCH version — validate it parses as such.
353                        if semver::VersionReq::parse(ver).is_err() {
354                            return Err(PackageError::InvalidVersion(ver.clone()));
355                        }
356                    } else {
357                        return Err(PackageError::MissingGitRef(name.clone()));
358                    }
359                } else {
360                    // Exactly one of branch/tag/rev may be set. A `version`
361                    // constraint co-declared with a ref is allowed (the ref
362                    // wins and the constraint becomes inert); only ref + ref
363                    // is rejected.
364                    let ref_count = dep.branch.is_some() as u8
365                        + dep.tag.is_some() as u8
366                        + dep.rev.is_some() as u8;
367                    if ref_count > 1 {
368                        return Err(PackageError::MultipleGitRefs(name.clone()));
369                    }
370                    for v in [dep.branch.as_ref(), dep.tag.as_ref(), dep.rev.as_ref()]
371                        .into_iter()
372                        .flatten()
373                    {
374                        if v.trim().is_empty() {
375                            return Err(PackageError::EmptyGitRef(name.clone()));
376                        }
377                    }
378                }
379            }
380            (false, false) => return Err(PackageError::MissingPath(name.clone())),
381        }
382
383        graph.insert(name.clone(), Vec::new());
384    }
385
386    if !graph.is_empty() {
387        if let Err(node) = topo_sort(&graph) {
388            return Err(PackageError::CircularDependency(node));
389        }
390    }
391
392    Ok(())
393}
394
395/// Validate a Git remote URL's scheme and basic shape (no network access).
396///
397/// Accepted forms:
398/// * SCP-like `git@host:org/repo` (no scheme),
399/// * URL forms with scheme `https`, `http`, `git`, or `ssh`,
400/// * Local repository paths (an absolute path on the current platform, e.g.
401///   `/abs/path` on Unix or `C:\abs\path` on Windows, plus `./rel`, `../rel`,
402///   `~/path`), which `git clone` accepts directly and are used by
403///   offline/hermetic tests.
404///
405/// The URL/reference must be non-empty. A bare host with no scheme and no path
406/// separator (e.g. `github.com/org/repo` without a scheme) is rejected as an
407/// invalid URL. This is a syntax/shape check only — no network connection is
408/// made.
409pub fn validate_git_url(url: &str) -> Result<(), PackageError> {
410    let trimmed = url.trim();
411    if trimmed.is_empty() {
412        return Err(PackageError::InvalidGitUrl(url.to_string()));
413    }
414
415    // SCP-like form: git@host:org/repo
416    if let Some(rest) = trimmed.strip_prefix("git@") {
417        let (host, _) = rest
418            .split_once(':')
419            .ok_or_else(|| PackageError::InvalidGitUrl(url.to_string()))?;
420        if host.is_empty() {
421            return Err(PackageError::InvalidGitUrl(url.to_string()));
422        }
423        return Ok(());
424    }
425
426    // scheme://host/... form.
427    if let Some((scheme, rest)) = trimmed.split_once("://") {
428        match scheme {
429            "https" | "http" | "git" | "ssh" => {}
430            other => return Err(PackageError::UnsupportedUrlScheme(other.to_string())),
431        }
432        let host = rest.split(['/', ':', '?', '#']).next().unwrap_or("");
433        if host.is_empty() {
434            return Err(PackageError::InvalidGitUrl(url.to_string()));
435        }
436        return Ok(());
437    }
438
439    // Local path form: an absolute filesystem path (platform-native, e.g.
440    // `/abs/path` on Unix or `C:\abs\path` on Windows), or a relative /
441    // home-anchored path (`./rel`, `../rel`, `~/path`). `git clone` accepts
442    // these directly. Reject bare hosts with no scheme and no path anchor
443    // (e.g. `github.com/org/repo` without a scheme), which `git` would not
444    // interpret as a local repository.
445    if Path::new(trimmed).is_absolute() || trimmed.starts_with('.') || trimmed.starts_with('~') {
446        return Ok(());
447    }
448
449    Err(PackageError::InvalidGitUrl(url.to_string()))
450}
451
452/// Validate an entire local package manifest: metadata + dependency graph.
453///
454/// This is what `sdkt package validate` calls. Never performs network
455/// operations. Returns `Ok(())` when the manifest is valid, or the first
456/// [`PackageError`] encountered (metadata checked before dependencies).
457pub fn validate_manifest(base_dir: &Path, config: &DevKitConfig) -> Result<(), PackageError> {
458    validate_package(config)?;
459    validate_dependencies(base_dir, config)?;
460    Ok(())
461}
462
463/// Parse a manifest TOML string into [`DevKitConfig`].
464///
465/// Thin wrapper over [`DevKitConfig::from_toml`] kept here so the package
466/// command has a single obvious entry point. A malformed manifest (including a
467/// duplicate `[package]`/`[dependencies]` key or an unsupported dependency
468/// source key) returns an `Err` that the CLI surfaces with a clear message.
469pub fn parse_manifest(content: &str) -> Result<DevKitConfig, toml::de::Error> {
470    DevKitConfig::from_toml(content)
471}
472
473// ===== Milestone 38 — Packaging & publishing workflow =====
474//
475// `pack` bundles a resolved project (`.sdkt.toml` + `sdkt.lock` + the git
476// checkouts under `.sdkt-cache/git/<key>`) into a portable offline artifact.
477// `publish_plan` is a read-only readiness check built entirely on
478// `verify_dependencies` + `compute_dependency_integrity` () — no new
479// verification algorithm, no network.
480//
481// All new git/hash/cache logic is *reused*; packing is a copy/archive operation
482// over the existing checkout tree and the descriptor is produced from data the
483// lock already carries.
484
485use std::time::{SystemTime, UNIX_EPOCH};
486
487/// One resolved dependency recorded in a [`PackageBundle`] descriptor.
488///
489/// Carries enough to locate and re-verify a bundled git checkout offline: the
490/// cache key (`.sdkt-cache/git/<key>`), the resolved commit, and the integrity
491/// hash computed by [`crate::lock::compute_dependency_integrity`].
492#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
493pub struct BundleEntry {
494    /// Dependency name (key under `[dependencies]`).
495    pub name: String,
496    /// Source kind: `local` or `git`.
497    pub source: String,
498    /// Git remote URL (empty for local path deps).
499    #[serde(default, skip_serializing_if = "String::is_empty")]
500    pub git_url: String,
501    /// Resolved commit SHA (empty for local path deps / not-yet-fetched).
502    #[serde(default, skip_serializing_if = "String::is_empty")]
503    pub commit_sha: String,
504    /// Integrity hash ("sha256:<hex>") of the cached checkout's tracked tree.
505    #[serde(default, skip_serializing_if = "String::is_empty")]
506    pub integrity: String,
507    /// Cache key used by [`crate::fetch::git_cache_key`] (git deps only).
508    #[serde(default, skip_serializing_if = "String::is_empty")]
509    pub cache_key: String,
510    /// Declared `version` constraint (), if any.
511    #[serde(default, skip_serializing_if = "String::is_empty")]
512    pub version: String,
513}
514
515/// A self-contained, offline package bundle descriptor ().
516///
517/// Produced by [`pack`] and written as `package.json` inside the artifact so a
518/// downstream consumer / CI can verify the bundle reproduces the original
519/// `sdkt.lock` and per-dependency integrity hashes exactly.
520#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
521pub struct PackageBundle {
522    /// Schema marker for the descriptor.
523    pub schema: String,
524    /// Package name (from `[package]`).
525    pub name: String,
526    /// Package version (from `[package]`).
527    pub version: String,
528    /// Artifact format: `tar.zst` or `dir`.
529    pub format: String,
530    /// Path to the produced artifact (file or directory).
531    pub out_path: String,
532    /// sha256 of the bundled `sdkt.lock` bytes (verifies lock equivalence).
533    pub lock_sha256: String,
534    /// Per-dependency resolved entries (for offline reconstruct + verify).
535    pub entries: Vec<BundleEntry>,
536    /// Bundle creation timestamp (Unix seconds; no external time crate).
537    pub created_at: u64,
538}
539
540/// Result of a `publish --dry-run` readiness check ().
541///
542/// `ready` is true only when every gate passes. `checks` lists each gate with
543/// its pass state and a human-readable detail, so the CLI can render a plan.
544#[derive(Debug, Clone, PartialEq, Eq)]
545pub struct PublishReadiness {
546    /// True when the package is ready to publish (all checks passed).
547    pub ready: bool,
548    /// `(check_name, passed, detail)` for every readiness gate.
549    pub checks: Vec<(String, bool, String)>,
550}
551
552impl PackageBundle {
553    /// Write this descriptor as `package.json` into `dir`.
554    pub fn write_descriptor(&self, dir: &Path) -> Result<PathBuf, PackageError> {
555        let json = serde_json::to_string_pretty(self)
556            .map_err(|e| PackageError::Other(format!("serialize bundle descriptor: {}", e)))?;
557        let path = dir.join("package.json");
558        std::fs::write(&path, json)
559            .map_err(|e| PackageError::Other(format!("write {}: {}", path.display(), e)))?;
560        Ok(path)
561    }
562}
563
564/// Recursively copy a directory tree (std only; no new dependency).
565fn copy_dir_all(src: &Path, dst: &Path) -> Result<(), PackageError> {
566    std::fs::create_dir_all(dst)
567        .map_err(|e| PackageError::Other(format!("create {}: {}", dst.display(), e)))?;
568    for entry in std::fs::read_dir(src)
569        .map_err(|e| PackageError::Other(format!("read {}: {}", src.display(), e)))?
570    {
571        let entry = entry
572            .map_err(|e| PackageError::Other(format!("read entry in {}: {}", src.display(), e)))?;
573        let path = entry.path();
574        let target = dst.join(entry.file_name());
575        if path.is_dir() {
576            copy_dir_all(&path, &target)?;
577        } else {
578            std::fs::copy(&path, &target).map_err(|e| {
579                PackageError::Other(format!(
580                    "copy {} -> {}: {}",
581                    path.display(),
582                    target.display(),
583                    e
584                ))
585            })?;
586        }
587    }
588    Ok(())
589}
590
591fn sha256_hex(bytes: &[u8]) -> String {
592    use sha2::{Digest, Sha256};
593    let mut h = Sha256::new();
594    h.update(bytes);
595    let digest = h.finalize();
596    let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
597    format!("sha256:{hex}")
598}
599
600/// Bundle a project into a portable offline artifact ().
601///
602/// Reads `.sdkt.toml` + `sdkt.lock` + the resolved git checkouts under
603/// `.sdkt-cache/git/<key>` (reusing [`crate::fetch::git_cache_key`] layout) and
604/// writes either:
605/// * a directory tree at `<out>/<name>-<version>/` (`--format dir`), or
606/// * a compressed tarball `<out>/<name>-<version>.tar.zst` (`--format tar.zst`).
607///
608/// In both cases a `package.json` descriptor is emitted recording the lock
609/// sha256 and per-dependency integrity so the bundle can be verified offline.
610/// This is a **copy/archive** operation only — it introduces no new caching,
611/// hashing, or git-clone logic; [`crate::lock::compute_dependency_integrity`]
612/// already hashes each checkout tree and is reused here.
613pub fn pack(base: &Path, out: &Path, format: &str) -> Result<PackageBundle, PackageError> {
614    if format != "tar.zst" && format != "dir" {
615        return Err(PackageError::Other(format!(
616            "unsupported --format '{}' (expected 'tar.zst' or 'dir')",
617            format
618        )));
619    }
620
621    let config = DevKitConfig::from_file(base.join(".sdkt.toml"))
622        .map_err(|e| PackageError::Other(format!("reading .sdkt.toml: {}", e)))?;
623    let pkg = config
624        .package
625        .as_ref()
626        .ok_or_else(|| PackageError::Other("manifest has no [package] table".to_string()))?;
627    let name = pkg.name.clone().unwrap_or_default();
628    let version = pkg.version.clone().unwrap_or_default();
629    if name.is_empty() || version.is_empty() {
630        return Err(PackageError::Other(
631            "package name and version are required to pack".to_string(),
632        ));
633    }
634
635    let lock_path = base.join("sdkt.lock");
636    if !lock_path.exists() {
637        return Err(PackageError::Other(
638            "sdkt.lock not found; run `sdkt package fetch` before packing".to_string(),
639        ));
640    }
641    let lock_bytes = std::fs::read(&lock_path)
642        .map_err(|e| PackageError::Other(format!("read sdkt.lock: {}", e)))?;
643    let lock_sha256 = sha256_hex(&lock_bytes);
644
645    let lock = crate::lock::read_lock(base)
646        .map_err(|e| PackageError::Other(format!("parse sdkt.lock: {}", e)))?;
647
648    let staging = out.join(format!("{}-{}", name, version));
649    // Clean any prior staging dir of the same name.
650    if staging.exists() {
651        std::fs::remove_dir_all(&staging).map_err(|e| {
652            PackageError::Other(format!("clean staging {}: {}", staging.display(), e))
653        })?;
654    }
655    std::fs::create_dir_all(&staging)
656        .map_err(|e| PackageError::Other(format!("create staging {}: {}", staging.display(), e)))?;
657
658    // Copy manifest + lock into the staging root.
659    std::fs::copy(base.join(".sdkt.toml"), staging.join(".sdkt.toml"))
660        .map_err(|e| PackageError::Other(format!("copy .sdkt.toml: {}", e)))?;
661    std::fs::write(staging.join("sdkt.lock"), &lock_bytes)
662        .map_err(|e| PackageError::Other(format!("write sdkt.lock: {}", e)))?;
663
664    let mut entries: Vec<BundleEntry> = Vec::new();
665    for entry in &lock.dependencies {
666        let dep = config.dependencies.get(&entry.name);
667        let integrity = match dep {
668            Some(d) => crate::lock::compute_dependency_integrity(base, d),
669            None => entry.integrity.clone(),
670        };
671        let (cache_key, src_checkout) = match dep {
672            Some(d) if d.git.is_some() => {
673                let key = crate::fetch::git_cache_key(d);
674                let checkout = base.join(".sdkt-cache").join("git").join(&key);
675                (key, Some(checkout))
676            }
677            _ => (String::new(), None),
678        };
679        // Stage the git checkout (local path deps are not in the cache; the plan
680        // bundles only the resolved git checkouts).
681        if let Some(checkout) = src_checkout {
682            if checkout.join(".git").exists() {
683                let dst = staging.join(".sdkt-cache").join("git").join(&cache_key);
684                copy_dir_all(&checkout, &dst)?;
685            }
686        }
687        entries.push(BundleEntry {
688            name: entry.name.clone(),
689            source: entry.source.clone(),
690            git_url: entry.git_url.clone(),
691            commit_sha: entry.commit_sha.clone(),
692            integrity,
693            cache_key,
694            version: entry.version.clone(),
695        });
696    }
697
698    let created_at = SystemTime::now()
699        .duration_since(UNIX_EPOCH)
700        .map(|d| d.as_secs())
701        .unwrap_or(0);
702    let mut bundle = PackageBundle {
703        schema: "sdkt.package.bundle/v1".to_string(),
704        name,
705        version,
706        format: format.to_string(),
707        out_path: String::new(),
708        lock_sha256,
709        entries,
710        created_at,
711    };
712    bundle.write_descriptor(&staging)?;
713
714    // Materialize the final artifact.
715    let final_out_path: PathBuf = if format == "tar.zst" {
716        let tar_path = out.join(format!("{}-{}.tar.zst", bundle.name, bundle.version));
717        {
718            let file = std::fs::File::create(&tar_path).map_err(|e| {
719                PackageError::Other(format!("create {}: {}", tar_path.display(), e))
720            })?;
721            let enc = zstd::Encoder::new(file, 0)
722                .map_err(|e| PackageError::Other(format!("zstd encoder: {}", e)))?;
723            {
724                let mut builder = tar::Builder::new(enc);
725                builder
726                    .append_dir_all(".", &staging)
727                    .map_err(|e| PackageError::Other(format!("tar build: {}", e)))?;
728                let enc = builder
729                    .into_inner()
730                    .map_err(|e| PackageError::Other(format!("tar finalize: {}", e)))?;
731                enc.finish()
732                    .map_err(|e| PackageError::Other(format!("zstd finish: {}", e)))?;
733            }
734        }
735        // Remove the staging dir; keep only the tarball.
736        std::fs::remove_dir_all(&staging).map_err(|e| {
737            PackageError::Other(format!("clean staging {}: {}", staging.display(), e))
738        })?;
739        tar_path
740    } else {
741        staging
742    };
743
744    bundle.out_path = final_out_path.to_string_lossy().to_string();
745    Ok(bundle)
746}
747
748/// Evaluate publish readiness for a project ( `publish --dry-run`).
749///
750/// Pure read-only validation built on [`crate::lock::verify_dependencies`]
751/// () plus manifest/lock presence. Detects: missing cache, lock drift,
752/// integrity mismatch, commit mismatch, reference change, and invalid package
753/// state. No network, no publish.
754pub fn publish_plan(base: &Path, config: &DevKitConfig) -> Result<PublishReadiness, PackageError> {
755    let mut checks: Vec<(String, bool, String)> = Vec::new();
756
757    // 1) Manifest validity (reuses the existing validator).
758    match validate_manifest(base, config) {
759        Ok(()) => checks.push((
760            "manifest-valid".to_string(),
761            true,
762            "manifest validated".to_string(),
763        )),
764        Err(e) => checks.push((
765            "manifest-valid".to_string(),
766            false,
767            format!("manifest invalid: {}", e),
768        )),
769    }
770
771    // 2) Lock presence + consistency (reuses verify_dependencies).
772    let report = crate::lock::verify_dependencies(base, config);
773    checks.push((
774        "lock-present".to_string(),
775        report.present,
776        if report.present {
777            "sdkt.lock present".to_string()
778        } else {
779            "sdkt.lock missing".to_string()
780        },
781    ));
782    checks.push((
783        "lock-consistent".to_string(),
784        report.consistent,
785        if report.consistent {
786            "lock consistent with manifest + cache".to_string()
787        } else {
788            format!("{} drift(s) detected", report.mismatches.len())
789        },
790    ));
791
792    // 3) Per-dependency drift detail (cache missing / integrity / commit / ref).
793    for m in &report.mismatches {
794        let detail = match m.kind {
795            crate::lock::DepMismatchKind::CacheMissing => {
796                format!("{}: cache checkout missing", m.name)
797            }
798            crate::lock::DepMismatchKind::IntegrityMismatch => {
799                format!("{}: integrity mismatch", m.name)
800            }
801            crate::lock::DepMismatchKind::CommitMismatch => format!("{}: commit mismatch", m.name),
802            crate::lock::DepMismatchKind::ReferenceChanged => {
803                format!("{}: reference changed", m.name)
804            }
805            crate::lock::DepMismatchKind::SourceChanged => format!("{}: source changed", m.name),
806            crate::lock::DepMismatchKind::MissingInLock => format!("{}: missing in lock", m.name),
807            crate::lock::DepMismatchKind::NotInManifest => format!("{}: not in manifest", m.name),
808            crate::lock::DepMismatchKind::PathMissing => format!("{}: local path missing", m.name),
809        };
810        checks.push((format!("dep:{}", m.name), false, detail));
811    }
812
813    let ready = checks.iter().all(|(_, ok, _)| *ok);
814    Ok(PublishReadiness { ready, checks })
815}
816
817/// Verify a reconstructed (unpacked) bundle reproduces the original
818/// `sdkt.lock` sha256 and per-git-dependency integrity exactly ( round-trip).
819///
820/// Reuses [`crate::fetch::git_bin`] to read each checkout's tree hash the same
821/// way [`crate::lock::compute_dependency_integrity`] does, so no hash logic is
822/// duplicated.
823pub fn verify_bundle_equivalence(
824    unpacked_base: &Path,
825    bundle: &PackageBundle,
826) -> Result<bool, PackageError> {
827    let lock_path = unpacked_base.join("sdkt.lock");
828    let lock_bytes = std::fs::read(&lock_path)
829        .map_err(|e| PackageError::Other(format!("read sdkt.lock: {}", e)))?;
830    if sha256_hex(&lock_bytes) != bundle.lock_sha256 {
831        return Ok(false);
832    }
833    for entry in &bundle.entries {
834        if entry.source != "git" || entry.cache_key.is_empty() {
835            continue;
836        }
837        let checkout = unpacked_base
838            .join(".sdkt-cache")
839            .join("git")
840            .join(&entry.cache_key);
841        if !checkout.join(".git").exists() {
842            return Ok(false);
843        }
844        let out = std::process::Command::new(crate::fetch::git_bin())
845            .current_dir(&checkout)
846            .args(["rev-parse", "HEAD^{tree}"])
847            .output();
848        match out {
849            Ok(o) if o.status.success() => {
850                let tree = String::from_utf8_lossy(&o.stdout).trim().to_string();
851                if format!("sha256:{}", tree) != entry.integrity {
852                    return Ok(false);
853                }
854            }
855            _ => return Ok(false),
856        }
857    }
858    Ok(true)
859}
860
861/// Reconstruct a project from a bundle artifact ( round-trip).
862///
863/// * `tar.zst` — decompresses (zstd) and extracts the tar into `dest`.
864/// * `dir` — copies the directory tree into `dest`.
865///
866/// The reconstructed tree can then be verified offline with
867/// [`verify_bundle_equivalence`]. No new archive/hash logic: it reuses the
868/// same `tar` / `zstd` crates and the `copy_dir_all` helper used by [`pack`].
869pub fn unpack(artifact: &Path, dest: &Path) -> Result<PathBuf, PackageError> {
870    std::fs::create_dir_all(dest)
871        .map_err(|e| PackageError::Other(format!("create {}: {}", dest.display(), e)))?;
872    let name = artifact
873        .file_name()
874        .map(|n| n.to_string_lossy().to_string())
875        .unwrap_or_default();
876    let is_tar = name.ends_with(".tar.zst") || name.ends_with(".tgz");
877    if is_tar {
878        let file = std::fs::File::open(artifact)
879            .map_err(|e| PackageError::Other(format!("open {}: {}", artifact.display(), e)))?;
880        let dec = zstd::Decoder::new(file)
881            .map_err(|e| PackageError::Other(format!("zstd decoder: {}", e)))?;
882        let mut ar = tar::Archive::new(dec);
883        ar.unpack(dest)
884            .map_err(|e| PackageError::Other(format!("tar extract: {}", e)))?;
885    } else {
886        // Directory-format bundle: copy its contents into `dest`.
887        copy_dir_contents(artifact, dest)?;
888    }
889    Ok(dest.to_path_buf())
890}
891
892/// Copy the *contents* of `src` into `dst` (does not create a `src`-named
893/// subdirectory), reusing [`copy_dir_all`] per entry.
894fn copy_dir_contents(src: &Path, dst: &Path) -> Result<(), PackageError> {
895    std::fs::create_dir_all(dst)
896        .map_err(|e| PackageError::Other(format!("create {}: {}", dst.display(), e)))?;
897    for entry in std::fs::read_dir(src)
898        .map_err(|e| PackageError::Other(format!("read {}: {}", src.display(), e)))?
899    {
900        let entry = entry
901            .map_err(|e| PackageError::Other(format!("read entry in {}: {}", src.display(), e)))?;
902        let path = entry.path();
903        let target = dst.join(entry.file_name());
904        if path.is_dir() {
905            copy_dir_all(&path, &target)?;
906        } else {
907            std::fs::copy(&path, &target).map_err(|e| {
908                PackageError::Other(format!(
909                    "copy {} -> {}: {}",
910                    path.display(),
911                    target.display(),
912                    e
913                ))
914            })?;
915        }
916    }
917    Ok(())
918}
919
920#[cfg(test)]
921mod tests {
922    use super::*;
923    use crate::config::{Dependency, LocalDependency, PackageConfig};
924    use crate::fetch::DependencyFetcher;
925    use std::collections::HashMap;
926
927    fn manifest(
928        name: Option<&str>,
929        version: Option<&str>,
930        deps: Vec<(&str, Option<&str>)>,
931    ) -> DevKitConfig {
932        let package = if name.is_some() || version.is_some() {
933            Some(PackageConfig {
934                name: name.map(|s| s.to_string()),
935                version: version.map(|s| s.to_string()),
936                description: None,
937            })
938        } else {
939            None
940        };
941        let mut dependencies = HashMap::new();
942        for (dname, dpath) in deps {
943            dependencies.insert(
944                dname.to_string(),
945                LocalDependency {
946                    path: dpath.map(|s| s.to_string()),
947                    ..Default::default()
948                },
949            );
950        }
951        DevKitConfig {
952            package,
953            dependencies,
954            ..Default::default()
955        }
956    }
957
958    fn temp_dir(label: &str) -> std::path::PathBuf {
959        let dir = std::env::temp_dir().join(format!(
960            "sdkt-pkg-{}-{}",
961            label,
962            std::time::SystemTime::now()
963                .duration_since(std::time::UNIX_EPOCH)
964                .unwrap()
965                .as_nanos()
966        ));
967        let _ = std::fs::remove_dir_all(&dir);
968        dir
969    }
970
971    #[test]
972    fn test_validate_package_ok() {
973        let cfg = manifest(Some("my-token"), Some("0.1.0"), vec![]);
974        assert!(validate_package(&cfg).is_ok());
975    }
976
977    #[test]
978    fn test_validate_missing_name() {
979        let cfg = manifest(None, Some("0.1.0"), vec![]);
980        assert!(matches!(
981            validate_package(&cfg),
982            Err(PackageError::MissingName)
983        ));
984    }
985
986    #[test]
987    fn test_validate_missing_version() {
988        let cfg = manifest(Some("my-token"), None, vec![]);
989        assert!(matches!(
990            validate_package(&cfg),
991            Err(PackageError::MissingVersion)
992        ));
993    }
994
995    #[test]
996    fn test_validate_invalid_version_format() {
997        for bad in ["1", "1.2", "x.y.z", "1.2.3.4", "v1.2.3", "01.2.3"] {
998            let cfg = manifest(Some("my-token"), Some(bad), vec![]);
999            assert!(
1000                matches!(validate_package(&cfg), Err(PackageError::InvalidVersion(_))),
1001                "version '{}' should be invalid",
1002                bad
1003            );
1004        }
1005    }
1006
1007    #[test]
1008    fn test_validate_valid_version_with_prerelease() {
1009        for ok in ["0.1.0", "1.2.3", "10.20.30", "1.0.0-alpha", "2.3.4+build.5"] {
1010            assert!(
1011                validate_version_format(ok).is_ok(),
1012                "version '{}' should be valid",
1013                ok
1014            );
1015        }
1016    }
1017
1018    #[test]
1019    fn test_validate_missing_path() {
1020        let cfg = manifest(Some("my-token"), Some("0.1.0"), vec![("math", None)]);
1021        match validate_dependencies(Path::new("."), &cfg) {
1022            Err(PackageError::MissingPath(d)) => assert_eq!(d, "math"),
1023            other => panic!("Expected MissingPath, got {:?}", other),
1024        }
1025    }
1026
1027    #[test]
1028    fn test_validate_self_dependency() {
1029        let cfg = manifest(
1030            Some("my-token"),
1031            Some("0.1.0"),
1032            vec![("my-token", Some("libs/token"))],
1033        );
1034        match validate_dependencies(Path::new("."), &cfg) {
1035            Err(PackageError::SelfDependency(d)) => assert_eq!(d, "my-token"),
1036            other => panic!("Expected SelfDependency, got {:?}", other),
1037        }
1038    }
1039
1040    #[test]
1041    fn test_validate_path_not_found() {
1042        let cfg = manifest(
1043            Some("my-token"),
1044            Some("0.1.0"),
1045            vec![("math", Some("../does-not-exist-xyz"))],
1046        );
1047        assert!(matches!(
1048            validate_dependencies(Path::new("."), &cfg),
1049            Err(PackageError::PathNotFound(_))
1050        ));
1051    }
1052
1053    #[test]
1054    fn test_validate_valid_local_deps() {
1055        let tmp = temp_dir("valid");
1056        std::fs::create_dir_all(tmp.join("math")).unwrap();
1057        std::fs::create_dir_all(tmp.join("auth")).unwrap();
1058
1059        let cfg = manifest(
1060            Some("my-token"),
1061            Some("0.1.0"),
1062            vec![("math", Some("math")), ("auth", Some("auth"))],
1063        );
1064        let res = validate_dependencies(&tmp, &cfg);
1065        assert!(res.is_ok(), "expected ok, got {:?}", res);
1066
1067        let _ = std::fs::remove_dir_all(&tmp);
1068    }
1069
1070    #[test]
1071    fn test_unsupported_source_no_longer_rejected_at_parse() {
1072        // Since `git` is a known dependency field, so the manifest
1073        // parses. Validation then rejects it because it lacks a reference.
1074        let toml_data = "\
1075[package]\nname = \"my-token\"\nversion = \"0.1.0\"\n\n[dependencies.math]\ngit = \"https://github.com/example/math\"\n";
1076        let parsed = parse_manifest(toml_data);
1077        assert!(
1078            parsed.is_ok(),
1079            "git dependency must parse (validation handles the rest)"
1080        );
1081        let cfg = parsed.unwrap();
1082        let res = validate_dependencies(Path::new("."), &cfg);
1083        assert!(
1084            matches!(
1085                res,
1086                Err(PackageError::MissingGitRef(_))
1087                    | Err(PackageError::MultipleGitRefs(_))
1088                    | Err(PackageError::EmptyGitRef(_))
1089                    | Err(PackageError::MissingGitUrl(_))
1090                    | Err(PackageError::MixedSources(_))
1091            ),
1092            "git dependency without a reference must be rejected: {:?}",
1093            res
1094        );
1095    }
1096
1097    #[test]
1098    fn test_validate_git_url_accepts_and_rejects() {
1099        // A platform-absolute local path (used by offline/hermetic tests).
1100        let abs_local = std::env::temp_dir()
1101            .join("sdkt-local-repo")
1102            .to_string_lossy()
1103            .into_owned();
1104        for ok in [
1105            "https://github.com/org/repo",
1106            "http://github.com/org/repo",
1107            "git://github.com/org/repo",
1108            "ssh://git@github.com/org/repo",
1109            "git@github.com:org/repo",
1110            abs_local.as_str(),
1111            "./local/repo",
1112            "../sibling/repo",
1113        ] {
1114            assert!(validate_git_url(ok).is_ok(), "url '{}' should be valid", ok);
1115        }
1116        for bad in [
1117            "",
1118            "ftp://github.com/org/repo",
1119            "file:///tmp/repo",
1120            "github.com/org/repo",
1121            "git@:org/repo",
1122            "https://",
1123        ] {
1124            assert!(
1125                validate_git_url(bad).is_err(),
1126                "url '{}' should be invalid",
1127                bad
1128            );
1129        }
1130    }
1131
1132    #[test]
1133    fn test_validate_git_tag_ok() {
1134        let cfg = DevKitConfig {
1135            package: Some(PackageConfig {
1136                name: Some("my-token".to_string()),
1137                version: Some("0.1.0".to_string()),
1138                description: None,
1139            }),
1140            dependencies: {
1141                let mut m = HashMap::new();
1142                m.insert(
1143                    "token".to_string(),
1144                    Dependency {
1145                        git: Some("https://github.com/org/token".to_string()),
1146                        tag: Some("v1.2.0".to_string()),
1147                        ..Default::default()
1148                    },
1149                );
1150                m
1151            },
1152            ..Default::default()
1153        };
1154        assert!(validate_dependencies(Path::new("."), &cfg).is_ok());
1155    }
1156
1157    #[test]
1158    fn test_validate_git_rejects_multiple_refs() {
1159        let cfg = DevKitConfig {
1160            package: Some(PackageConfig {
1161                name: Some("my-token".to_string()),
1162                version: Some("0.1.0".to_string()),
1163                description: None,
1164            }),
1165            dependencies: {
1166                let mut m = HashMap::new();
1167                m.insert(
1168                    "token".to_string(),
1169                    Dependency {
1170                        git: Some("https://github.com/org/token".to_string()),
1171                        tag: Some("v1.2.0".to_string()),
1172                        branch: Some("main".to_string()),
1173                        ..Default::default()
1174                    },
1175                );
1176                m
1177            },
1178            ..Default::default()
1179        };
1180        match validate_dependencies(Path::new("."), &cfg) {
1181            Err(PackageError::MultipleGitRefs(d)) => assert_eq!(d, "token"),
1182            other => panic!("Expected MultipleGitRefs, got {:?}", other),
1183        }
1184    }
1185
1186    #[test]
1187    fn test_validate_git_rejects_path_plus_git() {
1188        let cfg = DevKitConfig {
1189            package: Some(PackageConfig {
1190                name: Some("my-token".to_string()),
1191                version: Some("0.1.0".to_string()),
1192                description: None,
1193            }),
1194            dependencies: {
1195                let mut m = HashMap::new();
1196                m.insert(
1197                    "token".to_string(),
1198                    Dependency {
1199                        path: Some("local/token".to_string()),
1200                        git: Some("https://github.com/org/token".to_string()),
1201                        ..Default::default()
1202                    },
1203                );
1204                m
1205            },
1206            ..Default::default()
1207        };
1208        match validate_dependencies(Path::new("."), &cfg) {
1209            Err(PackageError::MixedSources(d)) => assert_eq!(d, "token"),
1210            other => panic!("Expected MixedSources, got {:?}", other),
1211        }
1212    }
1213
1214    #[test]
1215    fn test_validate_git_rejects_unsupported_scheme() {
1216        let cfg = DevKitConfig {
1217            package: Some(PackageConfig {
1218                name: Some("my-token".to_string()),
1219                version: Some("0.1.0".to_string()),
1220                description: None,
1221            }),
1222            dependencies: {
1223                let mut m = HashMap::new();
1224                m.insert(
1225                    "token".to_string(),
1226                    Dependency {
1227                        git: Some("ftp://github.com/org/token".to_string()),
1228                        tag: Some("v1.2.0".to_string()),
1229                        ..Default::default()
1230                    },
1231                );
1232                m
1233            },
1234            ..Default::default()
1235        };
1236        match validate_dependencies(Path::new("."), &cfg) {
1237            Err(PackageError::UnsupportedUrlScheme(s)) => {
1238                assert_eq!(s, "ftp")
1239            }
1240            other => panic!("Expected UnsupportedUrlScheme, got {:?}", other),
1241        }
1242    }
1243
1244    #[test]
1245    fn test_validate_git_rejects_empty_ref() {
1246        let cfg = DevKitConfig {
1247            package: Some(PackageConfig {
1248                name: Some("my-token".to_string()),
1249                version: Some("0.1.0".to_string()),
1250                description: None,
1251            }),
1252            dependencies: {
1253                let mut m = HashMap::new();
1254                m.insert(
1255                    "token".to_string(),
1256                    Dependency {
1257                        git: Some("https://github.com/org/token".to_string()),
1258                        tag: Some("".to_string()),
1259                        ..Default::default()
1260                    },
1261                );
1262                m
1263            },
1264            ..Default::default()
1265        };
1266        match validate_dependencies(Path::new("."), &cfg) {
1267            Err(PackageError::EmptyGitRef(d)) => assert_eq!(d, "token"),
1268            other => panic!("Expected EmptyGitRef, got {:?}", other),
1269        }
1270    }
1271
1272    #[test]
1273    fn test_duplicate_package_name_rejected_at_parse() {
1274        let toml_data = "\
1275[package]\nname = \"my-token\"\nversion = \"0.1.0\"\n\n[package]\nname = \"other\"\nversion = \"0.2.0\"\n";
1276        assert!(
1277            parse_manifest(toml_data).is_err(),
1278            "duplicate [package] table must be rejected"
1279        );
1280    }
1281
1282    #[test]
1283    fn test_topo_sort_deterministic_and_cyclic() {
1284        let mut g = HashMap::new();
1285        g.insert("a".to_string(), vec!["b".to_string()]);
1286        g.insert("b".to_string(), vec!["c".to_string()]);
1287        g.insert("c".to_string(), vec![]);
1288        let order = topo_sort(&g).unwrap();
1289        assert_eq!(order.len(), 3);
1290        let pos = |s: &str| order.iter().position(|x| x == s).unwrap();
1291        assert!(pos("c") < pos("b"));
1292        assert!(pos("b") < pos("a"));
1293
1294        let mut cyc = HashMap::new();
1295        cyc.insert("x".to_string(), vec!["y".to_string()]);
1296        cyc.insert("y".to_string(), vec!["x".to_string()]);
1297        assert!(topo_sort(&cyc).is_err());
1298    }
1299
1300    #[test]
1301    fn test_validate_manifest_full() {
1302        let tmp = temp_dir("full");
1303        std::fs::create_dir_all(tmp.join("math")).unwrap();
1304
1305        let cfg = manifest(
1306            Some("my-token"),
1307            Some("0.1.0"),
1308            vec![("math", Some("math"))],
1309        );
1310        assert!(validate_manifest(&tmp, &cfg).is_ok());
1311
1312        let _ = std::fs::remove_dir_all(&tmp);
1313    }
1314
1315    // --- packaging / publish-readiness tests ----------------------------
1316
1317    fn git_repo_with_tag(dir: &Path, tag: &str) -> String {
1318        std::fs::create_dir_all(dir)
1319            .unwrap_or_else(|e| panic!("create test repo dir {}: {}", dir.display(), e));
1320        let run = |args: &[&str]| {
1321            let o = std::process::Command::new(crate::fetch::git_bin())
1322                .current_dir(dir)
1323                .env("GIT_CONFIG_COUNT", "1")
1324                .env("GIT_CONFIG_KEY_0", "safe.directory")
1325                .env("GIT_CONFIG_VALUE_0", "*")
1326                .args(args)
1327                .output()
1328                .expect("git available");
1329            assert!(
1330                o.status.success(),
1331                "git {:?} failed: {}",
1332                args,
1333                String::from_utf8_lossy(&o.stderr)
1334            );
1335        };
1336        run(&["init", "-q"]);
1337        run(&["config", "user.email", "t@sdkt.local"]);
1338        run(&["config", "user.name", "sdkt test"]);
1339        std::fs::write(dir.join("lib.rs"), b"pub fn answer() -> u32 { 42 }\n").unwrap();
1340        run(&["add", "."]);
1341        run(&["commit", "-q", "-m", "initial"]);
1342        run(&["tag", tag]);
1343        let o = std::process::Command::new(crate::fetch::git_bin())
1344            .current_dir(dir)
1345            .args(["rev-parse", "HEAD"])
1346            .output()
1347            .unwrap();
1348        String::from_utf8_lossy(&o.stdout).trim().to_string()
1349    }
1350
1351    /// Build a project at `base` with one git dependency (tagged local repo),
1352    /// fetch it into `.sdkt-cache`, and write `sdkt.lock`. Returns the config.
1353    fn setup_packed_project(base: &Path, dep_url: &str) -> DevKitConfig {
1354        let pkg = DevKitConfig {
1355            package: Some(PackageConfig {
1356                name: Some("m38-app".to_string()),
1357                version: Some("0.3.0".to_string()),
1358                description: None,
1359            }),
1360            dependencies: {
1361                let mut m = HashMap::new();
1362                m.insert(
1363                    "math".to_string(),
1364                    Dependency {
1365                        git: Some(dep_url.to_string()),
1366                        tag: Some("v1.0.0".to_string()),
1367                        ..Default::default()
1368                    },
1369                );
1370                m
1371            },
1372            ..Default::default()
1373        };
1374        std::fs::create_dir_all(base).unwrap();
1375        std::fs::write(
1376            base.join(".sdkt.toml"),
1377            "[package]\nname = \"m38-app\"\nversion = \"0.3.0\"\n\n[dependencies.math]\ngit = \"DEP_URL\"\ntag = \"v1.0.0\"\n"
1378                .replace("DEP_URL", dep_url),
1379        )
1380        .unwrap();
1381
1382        // Fetch the dependency into the project cache.
1383        let cache = crate::fetch::GitFetcher::new(base.join(".sdkt-cache"));
1384        let outcome = cache
1385            .fetch("math", &pkg.dependencies["math"], false)
1386            .unwrap();
1387
1388        // Write the lock (reuses the single lock writer).
1389        let lock = crate::lock::LockFile {
1390            version: crate::lock::LOCK_VERSION,
1391            deploy_order: vec![],
1392            contracts: vec![],
1393            dependencies: crate::lock::lock_dependencies_resolved(base, &pkg, &[outcome]),
1394        };
1395        crate::lock::write_lock(base, &lock).unwrap();
1396        pkg
1397    }
1398
1399    #[test]
1400    fn pack_dir_roundtrip_preserves_lock_and_integrity() {
1401        let src = temp_dir("m38-src");
1402        git_repo_with_tag(&src, "v1.0.0");
1403        let url = src.to_string_lossy().replace('\\', "/");
1404
1405        let base = temp_dir("m38-base-dir");
1406        setup_packed_project(&base, &url);
1407
1408        let out = temp_dir("m38-out-dir");
1409        let bundle = pack(&base, &out, "dir").expect("pack dir");
1410        assert_eq!(bundle.format, "dir");
1411        assert_eq!(bundle.name, "m38-app");
1412        assert_eq!(bundle.version, "0.3.0");
1413        assert!(!bundle.entries.is_empty(), "should record the git dep");
1414        assert!(bundle.lock_sha256.starts_with("sha256:"));
1415
1416        let reconstructed = out.join("m38-app-0.3.0");
1417        assert!(reconstructed.join(".sdkt.toml").exists());
1418        assert!(reconstructed.join("sdkt.lock").exists());
1419        assert!(reconstructed.join("package.json").exists());
1420
1421        // The reconstructed tree must reproduce the lock + per-dep integrity.
1422        assert!(
1423            verify_bundle_equivalence(&reconstructed, &bundle).unwrap(),
1424            "dir round-trip must preserve lock + integrity"
1425        );
1426
1427        let _ = std::fs::remove_dir_all(&src);
1428        let _ = std::fs::remove_dir_all(&base);
1429        let _ = std::fs::remove_dir_all(&out);
1430    }
1431
1432    #[test]
1433    fn pack_tar_zst_roundtrip_preserves_lock_and_integrity() {
1434        let src = temp_dir("m38-src-tar");
1435        git_repo_with_tag(&src, "v1.0.0");
1436        let url = src.to_string_lossy().replace('\\', "/");
1437
1438        let base = temp_dir("m38-base-tar");
1439        setup_packed_project(&base, &url);
1440
1441        let out = temp_dir("m38-out-tar");
1442        let bundle = pack(&base, &out, "tar.zst").expect("pack tar.zst");
1443        assert_eq!(bundle.format, "tar.zst");
1444        let tarball = Path::new(&bundle.out_path);
1445        assert!(tarball.exists(), "tarball must exist");
1446        assert!(tarball.to_string_lossy().ends_with(".tar.zst"));
1447
1448        // Reconstruct from the tarball and verify equivalence via the embedded
1449        // `package.json` descriptor (no double-pack).
1450        let reconstruct = temp_dir("m38-reconstruct-tar");
1451        let _ = crate::package::unpack(tarball, &reconstruct).expect("unpack");
1452        let desc = std::fs::read_to_string(reconstruct.join("package.json")).unwrap();
1453        let rebuilt: PackageBundle = serde_json::from_str(&desc).unwrap();
1454        assert_eq!(rebuilt.lock_sha256, bundle.lock_sha256);
1455        assert!(
1456            verify_bundle_equivalence(&reconstruct, &rebuilt).unwrap(),
1457            "tar.zst round-trip must preserve lock + integrity"
1458        );
1459
1460        let _ = std::fs::remove_dir_all(&src);
1461        let _ = std::fs::remove_dir_all(&base);
1462        let _ = std::fs::remove_dir_all(&out);
1463        let _ = std::fs::remove_dir_all(&reconstruct);
1464    }
1465
1466    #[test]
1467    fn pack_rejects_unknown_format() {
1468        let src = temp_dir("m38-src-fmt");
1469        git_repo_with_tag(&src, "v1.0.0");
1470        let url = src.to_string_lossy().replace('\\', "/");
1471        let base = temp_dir("m38-base-fmt");
1472        setup_packed_project(&base, &url);
1473        let out = temp_dir("m38-out-fmt");
1474        let err = pack(&base, &out, "zip").unwrap_err();
1475        assert!(err.to_string().contains("unsupported --format"));
1476        let _ = std::fs::remove_dir_all(&src);
1477        let _ = std::fs::remove_dir_all(&base);
1478        let _ = std::fs::remove_dir_all(&out);
1479    }
1480
1481    #[test]
1482    fn publish_plan_ready_when_consistent() {
1483        let src = temp_dir("m38-src-ready");
1484        git_repo_with_tag(&src, "v1.0.0");
1485        let url = src.to_string_lossy().replace('\\', "/");
1486        let base = temp_dir("m38-base-ready");
1487        let cfg = setup_packed_project(&base, &url);
1488
1489        let readiness = publish_plan(&base, &cfg).expect("publish_plan");
1490        assert!(
1491            readiness.ready,
1492            "consistent project must be ready: {:?}",
1493            readiness.checks
1494        );
1495        // Every gate passes.
1496        assert!(readiness.checks.iter().all(|(_, ok, _)| *ok));
1497
1498        let _ = std::fs::remove_dir_all(&src);
1499        let _ = std::fs::remove_dir_all(&base);
1500    }
1501
1502    #[test]
1503    fn publish_plan_detects_missing_cache() {
1504        let src = temp_dir("m38-src-drift");
1505        git_repo_with_tag(&src, "v1.0.0");
1506        let url = src.to_string_lossy().replace('\\', "/");
1507        let base = temp_dir("m38-base-drift");
1508        let cfg = setup_packed_project(&base, &url);
1509
1510        // Remove the cached git checkout to simulate drift.
1511        let key = crate::fetch::git_cache_key(&cfg.dependencies["math"]);
1512        let checkout = base.join(".sdkt-cache").join("git").join(&key);
1513        assert!(checkout.exists());
1514        std::fs::remove_dir_all(&checkout).unwrap();
1515
1516        let readiness = publish_plan(&base, &cfg).expect("publish_plan");
1517        assert!(!readiness.ready, "missing cache must make publish unready");
1518        assert!(
1519            readiness
1520                .checks
1521                .iter()
1522                .any(|(n, ok, _)| n == "dep:math" && !*ok),
1523            "must report the math dep drift"
1524        );
1525
1526        let _ = std::fs::remove_dir_all(&src);
1527        let _ = std::fs::remove_dir_all(&base);
1528    }
1529}