1use crate::config::{DevKitConfig, PackageConfig};
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18use std::fmt;
19use std::path::{Path, PathBuf};
20
21#[derive(Debug, PartialEq)]
23pub enum PackageError {
24 MissingPackage,
26 MissingName,
28 MissingVersion,
30 InvalidVersion(String),
32 MissingPath(String),
34 UnsupportedSource(String),
36 SelfDependency(String),
38 CircularDependency(String),
40 DuplicateDependency(String),
42 PathNotFound(String),
44 MixedSources(String),
46 MissingGitUrl(String),
48 InvalidGitUrl(String),
50 UnsupportedUrlScheme(String),
52 MultipleGitRefs(String),
54 MissingGitRef(String),
56 EmptyGitRef(String),
58 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
139pub 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
165pub 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 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
200pub 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 if p.len() > 1 && p.starts_with('0') {
221 return Err(PackageError::InvalidVersion(version.to_string()));
222 }
223 }
224 Ok(())
225}
226
227pub 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(¤t) {
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, °ree) 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
287pub 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 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 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 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
395pub 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 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 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 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
452pub fn validate_manifest(base_dir: &Path, config: &DevKitConfig) -> Result<(), PackageError> {
458 validate_package(config)?;
459 validate_dependencies(base_dir, config)?;
460 Ok(())
461}
462
463pub fn parse_manifest(content: &str) -> Result<DevKitConfig, toml::de::Error> {
470 DevKitConfig::from_toml(content)
471}
472
473use std::time::{SystemTime, UNIX_EPOCH};
486
487#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
493pub struct BundleEntry {
494 pub name: String,
496 pub source: String,
498 #[serde(default, skip_serializing_if = "String::is_empty")]
500 pub git_url: String,
501 #[serde(default, skip_serializing_if = "String::is_empty")]
503 pub commit_sha: String,
504 #[serde(default, skip_serializing_if = "String::is_empty")]
506 pub integrity: String,
507 #[serde(default, skip_serializing_if = "String::is_empty")]
509 pub cache_key: String,
510 #[serde(default, skip_serializing_if = "String::is_empty")]
512 pub version: String,
513}
514
515#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
521pub struct PackageBundle {
522 pub schema: String,
524 pub name: String,
526 pub version: String,
528 pub format: String,
530 pub out_path: String,
532 pub lock_sha256: String,
534 pub entries: Vec<BundleEntry>,
536 pub created_at: u64,
538}
539
540#[derive(Debug, Clone, PartialEq, Eq)]
545pub struct PublishReadiness {
546 pub ready: bool,
548 pub checks: Vec<(String, bool, String)>,
550}
551
552impl PackageBundle {
553 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
564fn 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
600pub 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 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 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 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 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 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
748pub fn publish_plan(base: &Path, config: &DevKitConfig) -> Result<PublishReadiness, PackageError> {
755 let mut checks: Vec<(String, bool, String)> = Vec::new();
756
757 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 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 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
817pub 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
861pub 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 copy_dir_contents(artifact, dest)?;
888 }
889 Ok(dest.to_path_buf())
890}
891
892fn 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 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 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 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 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 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 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 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 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 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 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}