Skip to main content

sdkt_core/
package.rs

1//! Local package-manager foundation for the Soroban DevKit (sdkt), M35.0.
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 M38 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 (M37): 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 (M37), 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 (M37), 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` (M35.2) — 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 (M37), 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 (M38).
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 (M38).
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    format!("sha256:{:x}", h.finalize())
596}
597
598/// Bundle a project into a portable offline artifact (M38).
599///
600/// Reads `.sdkt.toml` + `sdkt.lock` + the resolved git checkouts under
601/// `.sdkt-cache/git/<key>` (reusing [`crate::fetch::git_cache_key`] layout) and
602/// writes either:
603/// * a directory tree at `<out>/<name>-<version>/` (`--format dir`), or
604/// * a compressed tarball `<out>/<name>-<version>.tar.zst` (`--format tar.zst`).
605///
606/// In both cases a `package.json` descriptor is emitted recording the lock
607/// sha256 and per-dependency integrity so the bundle can be verified offline.
608/// This is a **copy/archive** operation only — it introduces no new caching,
609/// hashing, or git-clone logic; [`crate::lock::compute_dependency_integrity`]
610/// already hashes each checkout tree and is reused here.
611pub fn pack(base: &Path, out: &Path, format: &str) -> Result<PackageBundle, PackageError> {
612    if format != "tar.zst" && format != "dir" {
613        return Err(PackageError::Other(format!(
614            "unsupported --format '{}' (expected 'tar.zst' or 'dir')",
615            format
616        )));
617    }
618
619    let config = DevKitConfig::from_file(base.join(".sdkt.toml"))
620        .map_err(|e| PackageError::Other(format!("reading .sdkt.toml: {}", e)))?;
621    let pkg = config
622        .package
623        .as_ref()
624        .ok_or_else(|| PackageError::Other("manifest has no [package] table".to_string()))?;
625    let name = pkg.name.clone().unwrap_or_default();
626    let version = pkg.version.clone().unwrap_or_default();
627    if name.is_empty() || version.is_empty() {
628        return Err(PackageError::Other(
629            "package name and version are required to pack".to_string(),
630        ));
631    }
632
633    let lock_path = base.join("sdkt.lock");
634    if !lock_path.exists() {
635        return Err(PackageError::Other(
636            "sdkt.lock not found; run `sdkt package fetch` before packing".to_string(),
637        ));
638    }
639    let lock_bytes = std::fs::read(&lock_path)
640        .map_err(|e| PackageError::Other(format!("read sdkt.lock: {}", e)))?;
641    let lock_sha256 = sha256_hex(&lock_bytes);
642
643    let lock = crate::lock::read_lock(base)
644        .map_err(|e| PackageError::Other(format!("parse sdkt.lock: {}", e)))?;
645
646    let staging = out.join(format!("{}-{}", name, version));
647    // Clean any prior staging dir of the same name.
648    if staging.exists() {
649        std::fs::remove_dir_all(&staging).map_err(|e| {
650            PackageError::Other(format!("clean staging {}: {}", staging.display(), e))
651        })?;
652    }
653    std::fs::create_dir_all(&staging)
654        .map_err(|e| PackageError::Other(format!("create staging {}: {}", staging.display(), e)))?;
655
656    // Copy manifest + lock into the staging root.
657    std::fs::copy(base.join(".sdkt.toml"), staging.join(".sdkt.toml"))
658        .map_err(|e| PackageError::Other(format!("copy .sdkt.toml: {}", e)))?;
659    std::fs::write(staging.join("sdkt.lock"), &lock_bytes)
660        .map_err(|e| PackageError::Other(format!("write sdkt.lock: {}", e)))?;
661
662    let mut entries: Vec<BundleEntry> = Vec::new();
663    for entry in &lock.dependencies {
664        let dep = config.dependencies.get(&entry.name);
665        let integrity = match dep {
666            Some(d) => crate::lock::compute_dependency_integrity(base, d),
667            None => entry.integrity.clone(),
668        };
669        let (cache_key, src_checkout) = match dep {
670            Some(d) if d.git.is_some() => {
671                let key = crate::fetch::git_cache_key(d);
672                let checkout = base.join(".sdkt-cache").join("git").join(&key);
673                (key, Some(checkout))
674            }
675            _ => (String::new(), None),
676        };
677        // Stage the git checkout (local path deps are not in the cache; the plan
678        // bundles only the resolved git checkouts — see milestone-38-plan.md §2).
679        if let Some(checkout) = src_checkout {
680            if checkout.join(".git").exists() {
681                let dst = staging.join(".sdkt-cache").join("git").join(&cache_key);
682                copy_dir_all(&checkout, &dst)?;
683            }
684        }
685        entries.push(BundleEntry {
686            name: entry.name.clone(),
687            source: entry.source.clone(),
688            git_url: entry.git_url.clone(),
689            commit_sha: entry.commit_sha.clone(),
690            integrity,
691            cache_key,
692            version: entry.version.clone(),
693        });
694    }
695
696    let created_at = SystemTime::now()
697        .duration_since(UNIX_EPOCH)
698        .map(|d| d.as_secs())
699        .unwrap_or(0);
700    let mut bundle = PackageBundle {
701        schema: "sdkt.package.bundle/v1".to_string(),
702        name,
703        version,
704        format: format.to_string(),
705        out_path: String::new(),
706        lock_sha256,
707        entries,
708        created_at,
709    };
710    bundle.write_descriptor(&staging)?;
711
712    // Materialize the final artifact.
713    let final_out_path: PathBuf = if format == "tar.zst" {
714        let tar_path = out.join(format!("{}-{}.tar.zst", bundle.name, bundle.version));
715        {
716            let file = std::fs::File::create(&tar_path).map_err(|e| {
717                PackageError::Other(format!("create {}: {}", tar_path.display(), e))
718            })?;
719            let enc = zstd::Encoder::new(file, 0)
720                .map_err(|e| PackageError::Other(format!("zstd encoder: {}", e)))?;
721            {
722                let mut builder = tar::Builder::new(enc);
723                builder
724                    .append_dir_all(".", &staging)
725                    .map_err(|e| PackageError::Other(format!("tar build: {}", e)))?;
726                let enc = builder
727                    .into_inner()
728                    .map_err(|e| PackageError::Other(format!("tar finalize: {}", e)))?;
729                enc.finish()
730                    .map_err(|e| PackageError::Other(format!("zstd finish: {}", e)))?;
731            }
732        }
733        // Remove the staging dir; keep only the tarball.
734        std::fs::remove_dir_all(&staging).map_err(|e| {
735            PackageError::Other(format!("clean staging {}: {}", staging.display(), e))
736        })?;
737        tar_path
738    } else {
739        staging
740    };
741
742    bundle.out_path = final_out_path.to_string_lossy().to_string();
743    Ok(bundle)
744}
745
746/// Evaluate publish readiness for a project (M38 `publish --dry-run`).
747///
748/// Pure read-only validation built on [`crate::lock::verify_dependencies`]
749/// (M35.2) plus manifest/lock presence. Detects: missing cache, lock drift,
750/// integrity mismatch, commit mismatch, reference change, and invalid package
751/// state. No network, no publish.
752pub fn publish_plan(base: &Path, config: &DevKitConfig) -> Result<PublishReadiness, PackageError> {
753    let mut checks: Vec<(String, bool, String)> = Vec::new();
754
755    // 1) Manifest validity (reuses the existing validator).
756    match validate_manifest(base, config) {
757        Ok(()) => checks.push((
758            "manifest-valid".to_string(),
759            true,
760            "manifest validated".to_string(),
761        )),
762        Err(e) => checks.push((
763            "manifest-valid".to_string(),
764            false,
765            format!("manifest invalid: {}", e),
766        )),
767    }
768
769    // 2) Lock presence + consistency (reuses M35.2 verify_dependencies).
770    let report = crate::lock::verify_dependencies(base, config);
771    checks.push((
772        "lock-present".to_string(),
773        report.present,
774        if report.present {
775            "sdkt.lock present".to_string()
776        } else {
777            "sdkt.lock missing".to_string()
778        },
779    ));
780    checks.push((
781        "lock-consistent".to_string(),
782        report.consistent,
783        if report.consistent {
784            "lock consistent with manifest + cache".to_string()
785        } else {
786            format!("{} drift(s) detected", report.mismatches.len())
787        },
788    ));
789
790    // 3) Per-dependency drift detail (cache missing / integrity / commit / ref).
791    for m in &report.mismatches {
792        let detail = match m.kind {
793            crate::lock::DepMismatchKind::CacheMissing => {
794                format!("{}: cache checkout missing", m.name)
795            }
796            crate::lock::DepMismatchKind::IntegrityMismatch => {
797                format!("{}: integrity mismatch", m.name)
798            }
799            crate::lock::DepMismatchKind::CommitMismatch => format!("{}: commit mismatch", m.name),
800            crate::lock::DepMismatchKind::ReferenceChanged => {
801                format!("{}: reference changed", m.name)
802            }
803            crate::lock::DepMismatchKind::SourceChanged => format!("{}: source changed", m.name),
804            crate::lock::DepMismatchKind::MissingInLock => format!("{}: missing in lock", m.name),
805            crate::lock::DepMismatchKind::NotInManifest => format!("{}: not in manifest", m.name),
806            crate::lock::DepMismatchKind::PathMissing => format!("{}: local path missing", m.name),
807        };
808        checks.push((format!("dep:{}", m.name), false, detail));
809    }
810
811    let ready = checks.iter().all(|(_, ok, _)| *ok);
812    Ok(PublishReadiness { ready, checks })
813}
814
815/// Verify a reconstructed (unpacked) bundle reproduces the original
816/// `sdkt.lock` sha256 and per-git-dependency integrity exactly (M38 round-trip).
817///
818/// Reuses [`crate::fetch::git_bin`] to read each checkout's tree hash the same
819/// way [`crate::lock::compute_dependency_integrity`] does, so no hash logic is
820/// duplicated.
821pub fn verify_bundle_equivalence(
822    unpacked_base: &Path,
823    bundle: &PackageBundle,
824) -> Result<bool, PackageError> {
825    let lock_path = unpacked_base.join("sdkt.lock");
826    let lock_bytes = std::fs::read(&lock_path)
827        .map_err(|e| PackageError::Other(format!("read sdkt.lock: {}", e)))?;
828    if sha256_hex(&lock_bytes) != bundle.lock_sha256 {
829        return Ok(false);
830    }
831    for entry in &bundle.entries {
832        if entry.source != "git" || entry.cache_key.is_empty() {
833            continue;
834        }
835        let checkout = unpacked_base
836            .join(".sdkt-cache")
837            .join("git")
838            .join(&entry.cache_key);
839        if !checkout.join(".git").exists() {
840            return Ok(false);
841        }
842        let out = std::process::Command::new(crate::fetch::git_bin())
843            .current_dir(&checkout)
844            .args(["rev-parse", "HEAD^{tree}"])
845            .output();
846        match out {
847            Ok(o) if o.status.success() => {
848                let tree = String::from_utf8_lossy(&o.stdout).trim().to_string();
849                if format!("sha256:{}", tree) != entry.integrity {
850                    return Ok(false);
851                }
852            }
853            _ => return Ok(false),
854        }
855    }
856    Ok(true)
857}
858
859/// Reconstruct a project from a bundle artifact (M38 round-trip).
860///
861/// * `tar.zst` — decompresses (zstd) and extracts the tar into `dest`.
862/// * `dir` — copies the directory tree into `dest`.
863///
864/// The reconstructed tree can then be verified offline with
865/// [`verify_bundle_equivalence`]. No new archive/hash logic: it reuses the
866/// same `tar` / `zstd` crates and the `copy_dir_all` helper used by [`pack`].
867pub fn unpack(artifact: &Path, dest: &Path) -> Result<PathBuf, PackageError> {
868    std::fs::create_dir_all(dest)
869        .map_err(|e| PackageError::Other(format!("create {}: {}", dest.display(), e)))?;
870    let name = artifact
871        .file_name()
872        .map(|n| n.to_string_lossy().to_string())
873        .unwrap_or_default();
874    let is_tar = name.ends_with(".tar.zst") || name.ends_with(".tgz");
875    if is_tar {
876        let file = std::fs::File::open(artifact)
877            .map_err(|e| PackageError::Other(format!("open {}: {}", artifact.display(), e)))?;
878        let dec = zstd::Decoder::new(file)
879            .map_err(|e| PackageError::Other(format!("zstd decoder: {}", e)))?;
880        let mut ar = tar::Archive::new(dec);
881        ar.unpack(dest)
882            .map_err(|e| PackageError::Other(format!("tar extract: {}", e)))?;
883    } else {
884        // Directory-format bundle: copy its contents into `dest`.
885        copy_dir_contents(artifact, dest)?;
886    }
887    Ok(dest.to_path_buf())
888}
889
890/// Copy the *contents* of `src` into `dst` (does not create a `src`-named
891/// subdirectory), reusing [`copy_dir_all`] per entry.
892fn copy_dir_contents(src: &Path, dst: &Path) -> Result<(), PackageError> {
893    std::fs::create_dir_all(dst)
894        .map_err(|e| PackageError::Other(format!("create {}: {}", dst.display(), e)))?;
895    for entry in std::fs::read_dir(src)
896        .map_err(|e| PackageError::Other(format!("read {}: {}", src.display(), e)))?
897    {
898        let entry = entry
899            .map_err(|e| PackageError::Other(format!("read entry in {}: {}", src.display(), e)))?;
900        let path = entry.path();
901        let target = dst.join(entry.file_name());
902        if path.is_dir() {
903            copy_dir_all(&path, &target)?;
904        } else {
905            std::fs::copy(&path, &target).map_err(|e| {
906                PackageError::Other(format!(
907                    "copy {} -> {}: {}",
908                    path.display(),
909                    target.display(),
910                    e
911                ))
912            })?;
913        }
914    }
915    Ok(())
916}
917
918#[cfg(test)]
919mod tests {
920    use super::*;
921    use crate::config::{Dependency, LocalDependency, PackageConfig};
922    use crate::fetch::DependencyFetcher;
923    use std::collections::HashMap;
924
925    fn manifest(
926        name: Option<&str>,
927        version: Option<&str>,
928        deps: Vec<(&str, Option<&str>)>,
929    ) -> DevKitConfig {
930        let package = if name.is_some() || version.is_some() {
931            Some(PackageConfig {
932                name: name.map(|s| s.to_string()),
933                version: version.map(|s| s.to_string()),
934                description: None,
935            })
936        } else {
937            None
938        };
939        let mut dependencies = HashMap::new();
940        for (dname, dpath) in deps {
941            dependencies.insert(
942                dname.to_string(),
943                LocalDependency {
944                    path: dpath.map(|s| s.to_string()),
945                    ..Default::default()
946                },
947            );
948        }
949        DevKitConfig {
950            package,
951            dependencies,
952            ..Default::default()
953        }
954    }
955
956    fn temp_dir(label: &str) -> std::path::PathBuf {
957        let dir = std::env::temp_dir().join(format!(
958            "sdkt-pkg-{}-{}",
959            label,
960            std::time::SystemTime::now()
961                .duration_since(std::time::UNIX_EPOCH)
962                .unwrap()
963                .as_nanos()
964        ));
965        let _ = std::fs::remove_dir_all(&dir);
966        dir
967    }
968
969    #[test]
970    fn test_validate_package_ok() {
971        let cfg = manifest(Some("my-token"), Some("0.1.0"), vec![]);
972        assert!(validate_package(&cfg).is_ok());
973    }
974
975    #[test]
976    fn test_validate_missing_name() {
977        let cfg = manifest(None, Some("0.1.0"), vec![]);
978        assert!(matches!(
979            validate_package(&cfg),
980            Err(PackageError::MissingName)
981        ));
982    }
983
984    #[test]
985    fn test_validate_missing_version() {
986        let cfg = manifest(Some("my-token"), None, vec![]);
987        assert!(matches!(
988            validate_package(&cfg),
989            Err(PackageError::MissingVersion)
990        ));
991    }
992
993    #[test]
994    fn test_validate_invalid_version_format() {
995        for bad in ["1", "1.2", "x.y.z", "1.2.3.4", "v1.2.3", "01.2.3"] {
996            let cfg = manifest(Some("my-token"), Some(bad), vec![]);
997            assert!(
998                matches!(validate_package(&cfg), Err(PackageError::InvalidVersion(_))),
999                "version '{}' should be invalid",
1000                bad
1001            );
1002        }
1003    }
1004
1005    #[test]
1006    fn test_validate_valid_version_with_prerelease() {
1007        for ok in ["0.1.0", "1.2.3", "10.20.30", "1.0.0-alpha", "2.3.4+build.5"] {
1008            assert!(
1009                validate_version_format(ok).is_ok(),
1010                "version '{}' should be valid",
1011                ok
1012            );
1013        }
1014    }
1015
1016    #[test]
1017    fn test_validate_missing_path() {
1018        let cfg = manifest(Some("my-token"), Some("0.1.0"), vec![("math", None)]);
1019        match validate_dependencies(Path::new("."), &cfg) {
1020            Err(PackageError::MissingPath(d)) => assert_eq!(d, "math"),
1021            other => panic!("Expected MissingPath, got {:?}", other),
1022        }
1023    }
1024
1025    #[test]
1026    fn test_validate_self_dependency() {
1027        let cfg = manifest(
1028            Some("my-token"),
1029            Some("0.1.0"),
1030            vec![("my-token", Some("libs/token"))],
1031        );
1032        match validate_dependencies(Path::new("."), &cfg) {
1033            Err(PackageError::SelfDependency(d)) => assert_eq!(d, "my-token"),
1034            other => panic!("Expected SelfDependency, got {:?}", other),
1035        }
1036    }
1037
1038    #[test]
1039    fn test_validate_path_not_found() {
1040        let cfg = manifest(
1041            Some("my-token"),
1042            Some("0.1.0"),
1043            vec![("math", Some("../does-not-exist-xyz"))],
1044        );
1045        assert!(matches!(
1046            validate_dependencies(Path::new("."), &cfg),
1047            Err(PackageError::PathNotFound(_))
1048        ));
1049    }
1050
1051    #[test]
1052    fn test_validate_valid_local_deps() {
1053        let tmp = temp_dir("valid");
1054        std::fs::create_dir_all(tmp.join("math")).unwrap();
1055        std::fs::create_dir_all(tmp.join("auth")).unwrap();
1056
1057        let cfg = manifest(
1058            Some("my-token"),
1059            Some("0.1.0"),
1060            vec![("math", Some("math")), ("auth", Some("auth"))],
1061        );
1062        let res = validate_dependencies(&tmp, &cfg);
1063        assert!(res.is_ok(), "expected ok, got {:?}", res);
1064
1065        let _ = std::fs::remove_dir_all(&tmp);
1066    }
1067
1068    #[test]
1069    fn test_unsupported_source_no_longer_rejected_at_parse() {
1070        // Since M35.1 `git` is a known dependency field, so the manifest
1071        // parses. Validation then rejects it because it lacks a reference.
1072        let toml_data = "\
1073[package]\nname = \"my-token\"\nversion = \"0.1.0\"\n\n[dependencies.math]\ngit = \"https://github.com/example/math\"\n";
1074        let parsed = parse_manifest(toml_data);
1075        assert!(
1076            parsed.is_ok(),
1077            "git dependency must parse (validation handles the rest)"
1078        );
1079        let cfg = parsed.unwrap();
1080        let res = validate_dependencies(Path::new("."), &cfg);
1081        assert!(
1082            matches!(
1083                res,
1084                Err(PackageError::MissingGitRef(_))
1085                    | Err(PackageError::MultipleGitRefs(_))
1086                    | Err(PackageError::EmptyGitRef(_))
1087                    | Err(PackageError::MissingGitUrl(_))
1088                    | Err(PackageError::MixedSources(_))
1089            ),
1090            "git dependency without a reference must be rejected: {:?}",
1091            res
1092        );
1093    }
1094
1095    #[test]
1096    fn test_validate_git_url_accepts_and_rejects() {
1097        // A platform-absolute local path (used by offline/hermetic tests).
1098        let abs_local = std::env::temp_dir()
1099            .join("sdkt-local-repo")
1100            .to_string_lossy()
1101            .into_owned();
1102        for ok in [
1103            "https://github.com/org/repo",
1104            "http://github.com/org/repo",
1105            "git://github.com/org/repo",
1106            "ssh://git@github.com/org/repo",
1107            "git@github.com:org/repo",
1108            abs_local.as_str(),
1109            "./local/repo",
1110            "../sibling/repo",
1111        ] {
1112            assert!(validate_git_url(ok).is_ok(), "url '{}' should be valid", ok);
1113        }
1114        for bad in [
1115            "",
1116            "ftp://github.com/org/repo",
1117            "file:///tmp/repo",
1118            "github.com/org/repo",
1119            "git@:org/repo",
1120            "https://",
1121        ] {
1122            assert!(
1123                validate_git_url(bad).is_err(),
1124                "url '{}' should be invalid",
1125                bad
1126            );
1127        }
1128    }
1129
1130    #[test]
1131    fn test_validate_git_tag_ok() {
1132        let cfg = DevKitConfig {
1133            package: Some(PackageConfig {
1134                name: Some("my-token".to_string()),
1135                version: Some("0.1.0".to_string()),
1136                description: None,
1137            }),
1138            dependencies: {
1139                let mut m = HashMap::new();
1140                m.insert(
1141                    "token".to_string(),
1142                    Dependency {
1143                        git: Some("https://github.com/org/token".to_string()),
1144                        tag: Some("v1.2.0".to_string()),
1145                        ..Default::default()
1146                    },
1147                );
1148                m
1149            },
1150            ..Default::default()
1151        };
1152        assert!(validate_dependencies(Path::new("."), &cfg).is_ok());
1153    }
1154
1155    #[test]
1156    fn test_validate_git_rejects_multiple_refs() {
1157        let cfg = DevKitConfig {
1158            package: Some(PackageConfig {
1159                name: Some("my-token".to_string()),
1160                version: Some("0.1.0".to_string()),
1161                description: None,
1162            }),
1163            dependencies: {
1164                let mut m = HashMap::new();
1165                m.insert(
1166                    "token".to_string(),
1167                    Dependency {
1168                        git: Some("https://github.com/org/token".to_string()),
1169                        tag: Some("v1.2.0".to_string()),
1170                        branch: Some("main".to_string()),
1171                        ..Default::default()
1172                    },
1173                );
1174                m
1175            },
1176            ..Default::default()
1177        };
1178        match validate_dependencies(Path::new("."), &cfg) {
1179            Err(PackageError::MultipleGitRefs(d)) => assert_eq!(d, "token"),
1180            other => panic!("Expected MultipleGitRefs, got {:?}", other),
1181        }
1182    }
1183
1184    #[test]
1185    fn test_validate_git_rejects_path_plus_git() {
1186        let cfg = DevKitConfig {
1187            package: Some(PackageConfig {
1188                name: Some("my-token".to_string()),
1189                version: Some("0.1.0".to_string()),
1190                description: None,
1191            }),
1192            dependencies: {
1193                let mut m = HashMap::new();
1194                m.insert(
1195                    "token".to_string(),
1196                    Dependency {
1197                        path: Some("local/token".to_string()),
1198                        git: Some("https://github.com/org/token".to_string()),
1199                        ..Default::default()
1200                    },
1201                );
1202                m
1203            },
1204            ..Default::default()
1205        };
1206        match validate_dependencies(Path::new("."), &cfg) {
1207            Err(PackageError::MixedSources(d)) => assert_eq!(d, "token"),
1208            other => panic!("Expected MixedSources, got {:?}", other),
1209        }
1210    }
1211
1212    #[test]
1213    fn test_validate_git_rejects_unsupported_scheme() {
1214        let cfg = DevKitConfig {
1215            package: Some(PackageConfig {
1216                name: Some("my-token".to_string()),
1217                version: Some("0.1.0".to_string()),
1218                description: None,
1219            }),
1220            dependencies: {
1221                let mut m = HashMap::new();
1222                m.insert(
1223                    "token".to_string(),
1224                    Dependency {
1225                        git: Some("ftp://github.com/org/token".to_string()),
1226                        tag: Some("v1.2.0".to_string()),
1227                        ..Default::default()
1228                    },
1229                );
1230                m
1231            },
1232            ..Default::default()
1233        };
1234        match validate_dependencies(Path::new("."), &cfg) {
1235            Err(PackageError::UnsupportedUrlScheme(s)) => {
1236                assert_eq!(s, "ftp")
1237            }
1238            other => panic!("Expected UnsupportedUrlScheme, got {:?}", other),
1239        }
1240    }
1241
1242    #[test]
1243    fn test_validate_git_rejects_empty_ref() {
1244        let cfg = DevKitConfig {
1245            package: Some(PackageConfig {
1246                name: Some("my-token".to_string()),
1247                version: Some("0.1.0".to_string()),
1248                description: None,
1249            }),
1250            dependencies: {
1251                let mut m = HashMap::new();
1252                m.insert(
1253                    "token".to_string(),
1254                    Dependency {
1255                        git: Some("https://github.com/org/token".to_string()),
1256                        tag: Some("".to_string()),
1257                        ..Default::default()
1258                    },
1259                );
1260                m
1261            },
1262            ..Default::default()
1263        };
1264        match validate_dependencies(Path::new("."), &cfg) {
1265            Err(PackageError::EmptyGitRef(d)) => assert_eq!(d, "token"),
1266            other => panic!("Expected EmptyGitRef, got {:?}", other),
1267        }
1268    }
1269
1270    #[test]
1271    fn test_duplicate_package_name_rejected_at_parse() {
1272        let toml_data = "\
1273[package]\nname = \"my-token\"\nversion = \"0.1.0\"\n\n[package]\nname = \"other\"\nversion = \"0.2.0\"\n";
1274        assert!(
1275            parse_manifest(toml_data).is_err(),
1276            "duplicate [package] table must be rejected"
1277        );
1278    }
1279
1280    #[test]
1281    fn test_topo_sort_deterministic_and_cyclic() {
1282        let mut g = HashMap::new();
1283        g.insert("a".to_string(), vec!["b".to_string()]);
1284        g.insert("b".to_string(), vec!["c".to_string()]);
1285        g.insert("c".to_string(), vec![]);
1286        let order = topo_sort(&g).unwrap();
1287        assert_eq!(order.len(), 3);
1288        let pos = |s: &str| order.iter().position(|x| x == s).unwrap();
1289        assert!(pos("c") < pos("b"));
1290        assert!(pos("b") < pos("a"));
1291
1292        let mut cyc = HashMap::new();
1293        cyc.insert("x".to_string(), vec!["y".to_string()]);
1294        cyc.insert("y".to_string(), vec!["x".to_string()]);
1295        assert!(topo_sort(&cyc).is_err());
1296    }
1297
1298    #[test]
1299    fn test_validate_manifest_full() {
1300        let tmp = temp_dir("full");
1301        std::fs::create_dir_all(tmp.join("math")).unwrap();
1302
1303        let cfg = manifest(
1304            Some("my-token"),
1305            Some("0.1.0"),
1306            vec![("math", Some("math"))],
1307        );
1308        assert!(validate_manifest(&tmp, &cfg).is_ok());
1309
1310        let _ = std::fs::remove_dir_all(&tmp);
1311    }
1312
1313    // --- M38 packaging / publish-readiness tests ----------------------------
1314
1315    fn git_repo_with_tag(dir: &Path, tag: &str) -> String {
1316        std::fs::create_dir_all(dir)
1317            .unwrap_or_else(|e| panic!("create test repo dir {}: {}", dir.display(), e));
1318        let run = |args: &[&str]| {
1319            let o = std::process::Command::new(crate::fetch::git_bin())
1320                .current_dir(dir)
1321                .env("GIT_CONFIG_COUNT", "1")
1322                .env("GIT_CONFIG_KEY_0", "safe.directory")
1323                .env("GIT_CONFIG_VALUE_0", "*")
1324                .args(args)
1325                .output()
1326                .expect("git available");
1327            assert!(
1328                o.status.success(),
1329                "git {:?} failed: {}",
1330                args,
1331                String::from_utf8_lossy(&o.stderr)
1332            );
1333        };
1334        run(&["init", "-q"]);
1335        run(&["config", "user.email", "t@sdkt.local"]);
1336        run(&["config", "user.name", "sdkt test"]);
1337        std::fs::write(dir.join("lib.rs"), b"pub fn answer() -> u32 { 42 }\n").unwrap();
1338        run(&["add", "."]);
1339        run(&["commit", "-q", "-m", "initial"]);
1340        run(&["tag", tag]);
1341        let o = std::process::Command::new(crate::fetch::git_bin())
1342            .current_dir(dir)
1343            .args(["rev-parse", "HEAD"])
1344            .output()
1345            .unwrap();
1346        String::from_utf8_lossy(&o.stdout).trim().to_string()
1347    }
1348
1349    /// Build a project at `base` with one git dependency (tagged local repo),
1350    /// fetch it into `.sdkt-cache`, and write `sdkt.lock`. Returns the config.
1351    fn setup_packed_project(base: &Path, dep_url: &str) -> DevKitConfig {
1352        let pkg = DevKitConfig {
1353            package: Some(PackageConfig {
1354                name: Some("m38-app".to_string()),
1355                version: Some("0.3.0".to_string()),
1356                description: None,
1357            }),
1358            dependencies: {
1359                let mut m = HashMap::new();
1360                m.insert(
1361                    "math".to_string(),
1362                    Dependency {
1363                        git: Some(dep_url.to_string()),
1364                        tag: Some("v1.0.0".to_string()),
1365                        ..Default::default()
1366                    },
1367                );
1368                m
1369            },
1370            ..Default::default()
1371        };
1372        std::fs::create_dir_all(base).unwrap();
1373        std::fs::write(
1374            base.join(".sdkt.toml"),
1375            "[package]\nname = \"m38-app\"\nversion = \"0.3.0\"\n\n[dependencies.math]\ngit = \"DEP_URL\"\ntag = \"v1.0.0\"\n"
1376                .replace("DEP_URL", dep_url),
1377        )
1378        .unwrap();
1379
1380        // Fetch the dependency into the project cache.
1381        let cache = crate::fetch::GitFetcher::new(base.join(".sdkt-cache"));
1382        let outcome = cache
1383            .fetch("math", &pkg.dependencies["math"], false)
1384            .unwrap();
1385
1386        // Write the lock (reuses the single lock writer).
1387        let lock = crate::lock::LockFile {
1388            version: crate::lock::LOCK_VERSION,
1389            deploy_order: vec![],
1390            contracts: vec![],
1391            dependencies: crate::lock::lock_dependencies_resolved(base, &pkg, &[outcome]),
1392        };
1393        crate::lock::write_lock(base, &lock).unwrap();
1394        pkg
1395    }
1396
1397    #[test]
1398    fn pack_dir_roundtrip_preserves_lock_and_integrity() {
1399        let src = temp_dir("m38-src");
1400        git_repo_with_tag(&src, "v1.0.0");
1401        let url = src.to_string_lossy().replace('\\', "/");
1402
1403        let base = temp_dir("m38-base-dir");
1404        setup_packed_project(&base, &url);
1405
1406        let out = temp_dir("m38-out-dir");
1407        let bundle = pack(&base, &out, "dir").expect("pack dir");
1408        assert_eq!(bundle.format, "dir");
1409        assert_eq!(bundle.name, "m38-app");
1410        assert_eq!(bundle.version, "0.3.0");
1411        assert!(!bundle.entries.is_empty(), "should record the git dep");
1412        assert!(bundle.lock_sha256.starts_with("sha256:"));
1413
1414        let reconstructed = out.join("m38-app-0.3.0");
1415        assert!(reconstructed.join(".sdkt.toml").exists());
1416        assert!(reconstructed.join("sdkt.lock").exists());
1417        assert!(reconstructed.join("package.json").exists());
1418
1419        // The reconstructed tree must reproduce the lock + per-dep integrity.
1420        assert!(
1421            verify_bundle_equivalence(&reconstructed, &bundle).unwrap(),
1422            "dir round-trip must preserve lock + integrity"
1423        );
1424
1425        let _ = std::fs::remove_dir_all(&src);
1426        let _ = std::fs::remove_dir_all(&base);
1427        let _ = std::fs::remove_dir_all(&out);
1428    }
1429
1430    #[test]
1431    fn pack_tar_zst_roundtrip_preserves_lock_and_integrity() {
1432        let src = temp_dir("m38-src-tar");
1433        git_repo_with_tag(&src, "v1.0.0");
1434        let url = src.to_string_lossy().replace('\\', "/");
1435
1436        let base = temp_dir("m38-base-tar");
1437        setup_packed_project(&base, &url);
1438
1439        let out = temp_dir("m38-out-tar");
1440        let bundle = pack(&base, &out, "tar.zst").expect("pack tar.zst");
1441        assert_eq!(bundle.format, "tar.zst");
1442        let tarball = Path::new(&bundle.out_path);
1443        assert!(tarball.exists(), "tarball must exist");
1444        assert!(tarball.to_string_lossy().ends_with(".tar.zst"));
1445
1446        // Reconstruct from the tarball and verify equivalence via the embedded
1447        // `package.json` descriptor (no double-pack).
1448        let reconstruct = temp_dir("m38-reconstruct-tar");
1449        let _ = crate::package::unpack(tarball, &reconstruct).expect("unpack");
1450        let desc = std::fs::read_to_string(reconstruct.join("package.json")).unwrap();
1451        let rebuilt: PackageBundle = serde_json::from_str(&desc).unwrap();
1452        assert_eq!(rebuilt.lock_sha256, bundle.lock_sha256);
1453        assert!(
1454            verify_bundle_equivalence(&reconstruct, &rebuilt).unwrap(),
1455            "tar.zst round-trip must preserve lock + integrity"
1456        );
1457
1458        let _ = std::fs::remove_dir_all(&src);
1459        let _ = std::fs::remove_dir_all(&base);
1460        let _ = std::fs::remove_dir_all(&out);
1461        let _ = std::fs::remove_dir_all(&reconstruct);
1462    }
1463
1464    #[test]
1465    fn pack_rejects_unknown_format() {
1466        let src = temp_dir("m38-src-fmt");
1467        git_repo_with_tag(&src, "v1.0.0");
1468        let url = src.to_string_lossy().replace('\\', "/");
1469        let base = temp_dir("m38-base-fmt");
1470        setup_packed_project(&base, &url);
1471        let out = temp_dir("m38-out-fmt");
1472        let err = pack(&base, &out, "zip").unwrap_err();
1473        assert!(err.to_string().contains("unsupported --format"));
1474        let _ = std::fs::remove_dir_all(&src);
1475        let _ = std::fs::remove_dir_all(&base);
1476        let _ = std::fs::remove_dir_all(&out);
1477    }
1478
1479    #[test]
1480    fn publish_plan_ready_when_consistent() {
1481        let src = temp_dir("m38-src-ready");
1482        git_repo_with_tag(&src, "v1.0.0");
1483        let url = src.to_string_lossy().replace('\\', "/");
1484        let base = temp_dir("m38-base-ready");
1485        let cfg = setup_packed_project(&base, &url);
1486
1487        let readiness = publish_plan(&base, &cfg).expect("publish_plan");
1488        assert!(
1489            readiness.ready,
1490            "consistent project must be ready: {:?}",
1491            readiness.checks
1492        );
1493        // Every gate passes.
1494        assert!(readiness.checks.iter().all(|(_, ok, _)| *ok));
1495
1496        let _ = std::fs::remove_dir_all(&src);
1497        let _ = std::fs::remove_dir_all(&base);
1498    }
1499
1500    #[test]
1501    fn publish_plan_detects_missing_cache() {
1502        let src = temp_dir("m38-src-drift");
1503        git_repo_with_tag(&src, "v1.0.0");
1504        let url = src.to_string_lossy().replace('\\', "/");
1505        let base = temp_dir("m38-base-drift");
1506        let cfg = setup_packed_project(&base, &url);
1507
1508        // Remove the cached git checkout to simulate drift.
1509        let key = crate::fetch::git_cache_key(&cfg.dependencies["math"]);
1510        let checkout = base.join(".sdkt-cache").join("git").join(&key);
1511        assert!(checkout.exists());
1512        std::fs::remove_dir_all(&checkout).unwrap();
1513
1514        let readiness = publish_plan(&base, &cfg).expect("publish_plan");
1515        assert!(!readiness.ready, "missing cache must make publish unready");
1516        assert!(
1517            readiness
1518                .checks
1519                .iter()
1520                .any(|(n, ok, _)| n == "dep:math" && !*ok),
1521            "must report the math dep drift"
1522        );
1523
1524        let _ = std::fs::remove_dir_all(&src);
1525        let _ = std::fs::remove_dir_all(&base);
1526    }
1527}