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 format!("sha256:{:x}", h.finalize())
596}
597
598pub 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 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 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 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 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 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
746pub fn publish_plan(base: &Path, config: &DevKitConfig) -> Result<PublishReadiness, PackageError> {
753 let mut checks: Vec<(String, bool, String)> = Vec::new();
754
755 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 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 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
815pub 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
859pub 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 copy_dir_contents(artifact, dest)?;
886 }
887 Ok(dest.to_path_buf())
888}
889
890fn 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 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 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 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 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 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 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 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 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 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 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}