1use crate::config::{Dependency, DevKitConfig};
29use crate::fetch::{DependencyFetcher, FetchError, FetchOutcome, GitFetcher};
30use crate::lock::{lock_dependencies_resolved, read_lock, write_lock, DependencyLock, LockFile};
31use std::path::{Path, PathBuf};
32use std::process::Command;
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum UpdateStatus {
37 Unchanged,
39 Updated,
41 Pinned,
43 Constraint,
45 Error,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct UpdateChange {
52 pub name: String,
54 pub source: String,
56 pub status: UpdateStatus,
58 pub old_commit: String,
60 pub new_commit: String,
62 pub detail: String,
64 pub resolved_tag: Option<String>,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct UpdateReport {
73 pub checked: usize,
75 pub updated: usize,
77 pub unchanged: usize,
79 pub changes: Vec<UpdateChange>,
81}
82
83#[derive(Debug)]
85pub enum SyncError {
86 InvalidManifest(String),
88 MissingLock,
90 GitUnavailable,
92 Git {
94 args: String,
95 status: Option<i32>,
96 stderr: String,
97 },
98 UnknownReference(String),
100 NetworkFailure(String),
102 DetachedBranch(String),
105 Io {
107 context: String,
108 source: std::io::Error,
109 },
110}
111
112impl std::fmt::Display for SyncError {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 match self {
115 SyncError::InvalidManifest(m) => write!(f, "invalid manifest: {}", m),
116 SyncError::MissingLock => {
117 write!(f, "no sdkt.lock found; run `sdkt package fetch` first")
118 }
119 SyncError::GitUnavailable => {
120 write!(
121 f,
122 "the `git` executable is required but was not found on PATH"
123 )
124 }
125 SyncError::Git {
126 args,
127 status,
128 stderr,
129 } => write!(
130 f,
131 "git {} failed (exit {:?}): {}",
132 args,
133 status,
134 stderr.trim()
135 ),
136 SyncError::UnknownReference(r) => write!(f, "unknown git reference: {}", r),
137 SyncError::NetworkFailure(m) => {
138 write!(f, "network failure contacting git remote: {}", m)
139 }
140 SyncError::DetachedBranch(n) => write!(
141 f,
142 "dependency '{}' cache is detached (not on its declared branch)",
143 n
144 ),
145 SyncError::Io { context, source } => write!(f, "I/O error {}: {}", context, source),
146 }
147 }
148}
149
150impl std::error::Error for SyncError {}
151
152impl From<FetchError> for SyncError {
154 fn from(e: FetchError) -> Self {
155 match e {
156 FetchError::GitUnavailable => SyncError::GitUnavailable,
157 FetchError::Git {
158 args,
159 status,
160 stderr,
161 } => {
162 if stderr.contains("Could not resolve host")
163 || stderr.contains("timed out")
164 || stderr.contains("Connection refused")
165 || stderr.contains("unable to access")
166 {
167 SyncError::NetworkFailure(format!("git {} failed: {}", args, stderr.trim()))
168 } else {
169 SyncError::Git {
170 args,
171 status,
172 stderr,
173 }
174 }
175 }
176 other => SyncError::Git {
177 args: String::new(),
178 status: None,
179 stderr: other.to_string(),
180 },
181 }
182 }
183}
184
185fn cache_root(base: &Path) -> PathBuf {
194 base.join(".sdkt-cache")
195}
196
197fn git_checkout(base: &Path, url: &str, reference: &str) -> PathBuf {
199 let key = crate::fetch::git_cache_key(&crate::config::Dependency {
200 git: Some(url.to_string()),
201 tag: if reference.starts_with("tag:") {
202 Some(reference.trim_start_matches("tag:").to_string())
203 } else {
204 None
205 },
206 branch: if reference.starts_with("branch:") {
207 Some(reference.trim_start_matches("branch:").to_string())
208 } else {
209 None
210 },
211 rev: if reference.starts_with("rev:") {
212 Some(reference.trim_start_matches("rev:").to_string())
213 } else {
214 None
215 },
216 ..Default::default()
217 });
218 cache_root(base).join("git").join(&key)
219}
220
221fn resolve_available_commit(
230 url: &str,
231 tag: &Option<String>,
232 branch: &Option<String>,
233 rev: &Option<String>,
234) -> Result<String, SyncError> {
235 if let Some(r) = rev {
237 return Ok(r.clone());
238 }
239
240 let reference = tag
241 .as_ref()
242 .map(|t| format!("refs/tags/{}", t))
243 .or_else(|| branch.as_ref().map(|b| format!("refs/heads/{}", b)))
244 .unwrap_or_default();
245 if reference.is_empty() {
246 return Err(SyncError::UnknownReference(
247 "dependency declares a git source without tag/branch/rev".to_string(),
248 ));
249 }
250
251 let probe = Command::new(crate::fetch::git_bin())
253 .arg("--version")
254 .output();
255 if probe.is_err() || !probe.unwrap().status.success() {
256 return Err(SyncError::GitUnavailable);
257 }
258
259 let out = Command::new(crate::fetch::git_bin())
260 .args(["ls-remote", url, &reference])
261 .output();
262 match out {
263 Ok(o) if o.status.success() => {
264 let stdout = String::from_utf8_lossy(&o.stdout);
265 let line = stdout.lines().next().unwrap_or("").trim().to_string();
266 if line.is_empty() {
267 return Err(SyncError::UnknownReference(reference));
268 }
269 let sha = line.split('\t').next().unwrap_or("").trim().to_string();
271 if sha.is_empty() {
272 return Err(SyncError::UnknownReference(reference));
273 }
274 Ok(sha)
275 }
276 Ok(o) => {
277 let stderr = String::from_utf8_lossy(&o.stderr).to_string();
278 if stderr.contains("Could not resolve host")
279 || stderr.contains("timed out")
280 || stderr.contains("Connection refused")
281 || stderr.contains("unable to access")
282 {
283 Err(SyncError::NetworkFailure(stderr))
284 } else {
285 Err(SyncError::Git {
286 args: format!("ls-remote {} {}", url, reference),
287 status: o.status.code(),
288 stderr,
289 })
290 }
291 }
292 Err(_) => Err(SyncError::GitUnavailable),
293 }
294}
295
296fn list_remote_tags(url: &str) -> Result<Vec<(String, String)>, SyncError> {
305 let probe = Command::new(crate::fetch::git_bin())
307 .arg("--version")
308 .output();
309 if probe.is_err() || !probe.unwrap().status.success() {
310 return Err(SyncError::GitUnavailable);
311 }
312
313 let out = Command::new(crate::fetch::git_bin())
314 .args(["ls-remote", "--tags", url])
315 .output();
316 match out {
317 Ok(o) if o.status.success() => {
318 let stdout = String::from_utf8_lossy(&o.stdout);
319 let mut tags = Vec::new();
320 for line in stdout.lines() {
321 let (sha, r#ref) = match line.split_once('\t') {
324 Some(x) => x,
325 None => continue,
326 };
327 if r#ref.ends_with("^{}") {
328 continue;
329 }
330 let tag = match r#ref.strip_prefix("refs/tags/") {
331 Some(t) => t.to_string(),
332 None => continue,
333 };
334 if sha.trim().is_empty() {
335 continue;
336 }
337 tags.push((tag, sha.trim().to_string()));
338 }
339 Ok(tags)
340 }
341 Ok(o) => {
342 let stderr = String::from_utf8_lossy(&o.stderr).to_string();
343 if stderr.contains("Could not resolve host")
344 || stderr.contains("timed out")
345 || stderr.contains("Connection refused")
346 || stderr.contains("unable to access")
347 {
348 Err(SyncError::NetworkFailure(stderr))
349 } else {
350 Err(SyncError::Git {
351 args: format!("ls-remote --tags {}", url),
352 status: o.status.code(),
353 stderr,
354 })
355 }
356 }
357 Err(_) => Err(SyncError::GitUnavailable),
358 }
359}
360
361pub(crate) fn resolve_version_constraint(
369 url: &str,
370 constraint: &str,
371) -> Result<Option<(String, String)>, SyncError> {
372 let tags = list_remote_tags(url)?;
373 Ok(crate::package::best_version_match(&tags, constraint))
374}
375
376fn git_cache_exists(base: &Path, dep: &crate::config::Dependency) -> bool {
378 let url = dep.git.clone().unwrap_or_default();
379 let reference = dep
380 .tag
381 .clone()
382 .map(|t| format!("tag:{}", t))
383 .or_else(|| dep.branch.clone().map(|b| format!("branch:{}", b)))
384 .or_else(|| dep.rev.clone().map(|r| format!("rev:{}", r)))
385 .unwrap_or_default();
386 let checkout = git_checkout(base, &url, &reference);
387 checkout.join(".git").exists()
388}
389
390pub fn plan_updates(base: &Path, config: &DevKitConfig) -> Result<UpdateReport, SyncError> {
398 if let Err(e) = crate::package::validate_manifest(base, config) {
399 return Err(SyncError::InvalidManifest(e.to_string()));
400 }
401 let lock = read_lock(base).map_err(|_| SyncError::MissingLock)?;
402 let locked: std::collections::HashMap<&str, &DependencyLock> = lock
403 .dependencies
404 .iter()
405 .map(|d| (d.name.as_str(), d))
406 .collect();
407
408 let mut changes = Vec::new();
409 for (name, dep) in &config.dependencies {
410 let old = locked
411 .get(name.as_str())
412 .map(|d| d.commit_sha.clone())
413 .unwrap_or_default();
414
415 if dep.git.is_none() {
416 changes.push(UpdateChange {
417 name: name.clone(),
418 source: "local".to_string(),
419 status: UpdateStatus::Unchanged,
420 old_commit: String::new(),
421 new_commit: String::new(),
422 detail: "local path dependency (no remote update)".to_string(),
423 resolved_tag: None,
424 });
425 continue;
426 }
427
428 let url = dep.git.clone().unwrap_or_default();
429
430 if dep.rev.is_some() {
432 changes.push(UpdateChange {
433 name: name.clone(),
434 source: "git".to_string(),
435 status: UpdateStatus::Pinned,
436 old_commit: old,
437 new_commit: dep.rev.clone().unwrap_or_default(),
438 detail: "pinned to rev (immutable)".to_string(),
439 resolved_tag: None,
440 });
441 continue;
442 }
443
444 if dep.version.is_some() && dep.tag.is_none() && dep.branch.is_none() && dep.rev.is_none() {
447 let constraint = dep.version.clone().unwrap();
448 match resolve_version_constraint(&url, &constraint) {
449 Ok(Some((tag, commit))) => {
450 let changed = !old.is_empty() && commit != old;
451 let first_lock = old.is_empty();
452 let (status, detail) = if first_lock {
453 (
454 UpdateStatus::Updated,
455 format!(
456 "not yet locked — would record {} (satisfies '{}')",
457 tag, constraint
458 ),
459 )
460 } else if changed {
461 (
462 UpdateStatus::Updated,
463 format!("update-available ({} satisfies '{}')", tag, constraint),
464 )
465 } else {
466 (
467 UpdateStatus::Unchanged,
468 format!("up-to-date ({} satisfies '{}')", tag, constraint),
469 )
470 };
471 changes.push(UpdateChange {
472 name: name.clone(),
473 source: "git".to_string(),
474 status,
475 old_commit: old,
476 new_commit: commit,
477 detail,
478 resolved_tag: Some(tag),
479 });
480 }
481 Ok(None) => {
482 changes.push(UpdateChange {
483 name: name.clone(),
484 source: "git".to_string(),
485 status: UpdateStatus::Constraint,
486 old_commit: old,
487 new_commit: String::new(),
488 detail: format!(
489 "constraint '{}' unsatisfied by available tags",
490 constraint
491 ),
492 resolved_tag: None,
493 });
494 }
495 Err(e) => {
496 let mut c = UpdateChange {
497 name: name.clone(),
498 source: "git".to_string(),
499 status: UpdateStatus::Error,
500 old_commit: old,
501 new_commit: String::new(),
502 detail: e.to_string(),
503 resolved_tag: None,
504 };
505 if !git_cache_exists(base, dep) {
506 c.detail = format!("missing cache (run `sdkt package fetch`): {}", e);
507 }
508 changes.push(c);
509 }
510 }
511 continue;
512 }
513
514 let available = match resolve_available_commit(&url, &dep.tag, &dep.branch, &dep.rev) {
516 Ok(c) => c,
517 Err(e) => {
518 changes.push(UpdateChange {
519 name: name.clone(),
520 source: "git".to_string(),
521 status: UpdateStatus::Error,
522 old_commit: old,
523 new_commit: String::new(),
524 detail: e.to_string(),
525 resolved_tag: None,
526 });
527 if !git_cache_exists(base, dep) {
530 changes.last_mut().unwrap().detail =
531 format!("missing cache (run `sdkt package fetch`): {}", e);
532 }
533 continue;
534 }
535 };
536
537 let changed = !old.is_empty() && available != old;
538 let first_lock = old.is_empty();
539 if changed || first_lock {
540 let detail = if first_lock {
541 "not yet locked — would record available commit".to_string()
542 } else {
543 format!(
544 "available commit {} != locked {}",
545 &available[..available.len().min(12)],
546 &old[..old.len().min(12)]
547 )
548 };
549 changes.push(UpdateChange {
550 name: name.clone(),
551 source: "git".to_string(),
552 status: UpdateStatus::Updated,
553 old_commit: old,
554 new_commit: available,
555 detail,
556 resolved_tag: None,
557 });
558 } else {
559 changes.push(UpdateChange {
560 name: name.clone(),
561 source: "git".to_string(),
562 status: UpdateStatus::Unchanged,
563 old_commit: old,
564 new_commit: available,
565 detail: "already at latest".to_string(),
566 resolved_tag: None,
567 });
568 }
569 }
570
571 Ok(summarize(changes))
572}
573
574pub fn apply_updates(
582 base: &Path,
583 config: &DevKitConfig,
584) -> Result<(UpdateReport, LockFile), SyncError> {
585 if let Err(e) = crate::package::validate_manifest(base, config) {
586 return Err(SyncError::InvalidManifest(e.to_string()));
587 }
588 let plan = plan_updates(base, config)?;
589
590 let mut fetched: Vec<FetchOutcome> = Vec::new();
592 let cache = cache_root(base);
596 let fetcher = GitFetcher::new(cache);
597
598 for change in &plan.changes {
599 if change.status != UpdateStatus::Updated {
600 continue;
601 }
602 let Some(dep) = config.dependencies.get(&change.name) else {
603 continue;
604 };
605 if dep.git.is_none() {
606 continue;
607 }
608 let fetch_dep = if let Some(tag) = &change.resolved_tag {
613 Dependency {
614 git: dep.git.clone(),
615 tag: Some(tag.clone()),
616 branch: None,
617 rev: None,
618 version: None,
619 ..Default::default()
620 }
621 } else {
622 dep.clone()
623 };
624 if fetch_dep.branch.is_some() {
627 let checkout = {
628 let url = fetch_dep.git.clone().unwrap_or_default();
629 let reference = format!("branch:{}", fetch_dep.branch.clone().unwrap());
630 git_checkout(base, &url, &reference)
631 };
632 if checkout.join(".git").exists() {
633 let sym = Command::new(crate::fetch::git_bin())
634 .current_dir(&checkout)
635 .args(["symbolic-ref", "-q", "HEAD"])
636 .output();
637 let on_branch = sym.map(|o| o.status.success()).unwrap_or(false);
638 if !on_branch {
639 return Err(SyncError::DetachedBranch(change.name.clone()));
640 }
641 }
642 }
643 let outcome = fetcher.fetch(&change.name, &fetch_dep, true)?;
645 fetched.push(outcome);
646 }
647
648 let mut lock = read_lock(base).map_err(|_| SyncError::MissingLock)?;
650 let new_deps = lock_dependencies_resolved(base, config, &fetched);
651 lock.dependencies = new_deps;
652 write_lock(base, &lock).map_err(|e| SyncError::Io {
653 context: format!("writing sdkt.lock: {}", e),
654 source: std::io::Error::other(e.to_string()),
655 })?;
656
657 Ok((plan, lock))
658}
659
660fn summarize(changes: Vec<UpdateChange>) -> UpdateReport {
662 let checked = changes.len();
663 let updated = changes
664 .iter()
665 .filter(|c| c.status == UpdateStatus::Updated)
666 .count();
667 let unchanged = changes
668 .iter()
669 .filter(|c| {
670 c.status == UpdateStatus::Unchanged
671 || c.status == UpdateStatus::Pinned
672 || c.status == UpdateStatus::Error
673 })
674 .count();
675 UpdateReport {
676 checked,
677 updated,
678 unchanged,
679 changes,
680 }
681}
682
683#[cfg(test)]
684mod tests {
685 use super::*;
686 use crate::config::{Dependency, PackageConfig};
687 use std::collections::HashMap;
688 use std::io::Write;
689
690 fn git_cmd(dir: &Path) -> Command {
693 let mut c = Command::new("git");
694 c.current_dir(dir)
695 .env("GIT_CONFIG_COUNT", "1")
696 .env("GIT_CONFIG_KEY_0", "safe.directory")
697 .env("GIT_CONFIG_VALUE_0", "*");
698 c
699 }
700
701 fn fresh_repo_dir() -> PathBuf {
702 use std::sync::atomic::{AtomicU64, Ordering};
703 static COUNTER: AtomicU64 = AtomicU64::new(0);
704 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
705 let nanos = std::time::SystemTime::now()
706 .duration_since(std::time::UNIX_EPOCH)
707 .unwrap()
708 .as_nanos();
709 let dir = std::env::temp_dir().join(format!(
710 "sdkt-sync-src-{}-{}-{}",
711 std::process::id(),
712 nanos,
713 n
714 ));
715 let _ = std::fs::remove_dir_all(&dir);
716 std::fs::create_dir_all(&dir).unwrap();
717 dir
718 }
719
720 fn commit(repo: &Path, content: &[u8]) {
721 {
722 let mut fh = std::fs::File::create(repo.join("lib.rs")).unwrap();
723 fh.write_all(content).unwrap();
724 }
725 git_cmd(repo).args(["add", "lib.rs"]).output().unwrap();
726 git_cmd(repo)
727 .args(["commit", "-q", "-m", "update"])
728 .output()
729 .unwrap();
730 }
731
732 fn make_repo() -> PathBuf {
733 let dir = fresh_repo_dir();
734 git_cmd(&dir).args(["init", "-q"]).output().unwrap();
735 git_cmd(&dir)
736 .args(["config", "user.email", "t@sdkt.local"])
737 .output()
738 .unwrap();
739 git_cmd(&dir)
740 .args(["config", "user.name", "sdkt test"])
741 .output()
742 .unwrap();
743 commit(&dir, b"pub fn answer() -> u32 { 42 }");
744 dir
745 }
746
747 fn head(repo: &Path) -> String {
748 let o = git_cmd(repo).args(["rev-parse", "HEAD"]).output().unwrap();
749 String::from_utf8_lossy(&o.stdout).trim().to_string()
750 }
751
752 fn config_with(name: &str, dep: Dependency) -> DevKitConfig {
753 let mut deps = HashMap::new();
754 deps.insert(name.to_string(), dep);
755 DevKitConfig {
756 package: Some(PackageConfig {
757 name: Some("app".to_string()),
758 version: Some("0.1.0".to_string()),
759 description: None,
760 }),
761 dependencies: deps,
762 ..Default::default()
763 }
764 }
765
766 fn write_lock_with(base: &Path, deps: Vec<DependencyLock>) {
767 let lock = LockFile {
768 version: crate::lock::LOCK_VERSION,
769 deploy_order: vec![],
770 contracts: vec![],
771 dependencies: deps,
772 };
773 crate::lock::write_lock(base, &lock).unwrap();
774 }
775
776 fn git_dep(
777 url: &str,
778 tag: Option<&str>,
779 branch: Option<&str>,
780 rev: Option<&str>,
781 ) -> Dependency {
782 Dependency {
783 git: Some(url.to_string()),
784 tag: tag.map(|s| s.to_string()),
785 branch: branch.map(|s| s.to_string()),
786 rev: rev.map(|s| s.to_string()),
787 ..Default::default()
788 }
789 }
790
791 fn git_dep_version(url: &str, version: &str) -> Dependency {
794 Dependency {
795 git: Some(url.to_string()),
796 version: Some(version.to_string()),
797 ..Default::default()
798 }
799 }
800
801 #[test]
804 fn plan_pinned_rev_never_updates() {
805 let src = make_repo();
806 let url = src.to_string_lossy().to_string();
807 let pin = head(&src);
808 commit(&src, b"pub fn answer() -> u32 { 43 }");
810
811 let cfg = config_with("dep", git_dep(&url, None, None, Some(&pin)));
812 let base = std::env::temp_dir().join(format!(
813 "sdkt-sync-rev-{}",
814 std::time::SystemTime::now()
815 .duration_since(std::time::UNIX_EPOCH)
816 .unwrap()
817 .as_nanos()
818 ));
819 let _ = std::fs::remove_dir_all(&base);
820 std::fs::create_dir_all(&base).unwrap();
821 write_lock_with(
822 &base,
823 vec![DependencyLock {
824 name: "dep".to_string(),
825 source: "git".to_string(),
826 git_url: url.clone(),
827 commit_sha: pin.clone(),
828 ..Default::default()
829 }],
830 );
831
832 let rep = plan_updates(&base, &cfg).unwrap();
833 assert_eq!(rep.changes.len(), 1);
834 assert_eq!(rep.changes[0].status, UpdateStatus::Pinned);
835 assert_eq!(rep.changes[0].new_commit, pin);
836 let _ = std::fs::remove_dir_all(&base);
837 let _ = std::fs::remove_dir_all(&src);
838 }
839
840 #[test]
841 fn plan_tag_update_detected() {
842 let src = make_repo();
843 let url = src.to_string_lossy().to_string();
844 git_cmd(&src).args(["tag", "v1.0.0"]).output().unwrap();
846 let v1 = head(&src);
847 commit(&src, b"pub fn answer() -> u32 { 43 }");
850 let v2 = head(&src);
851 git_cmd(&src)
852 .args(["tag", "-f", "v1.0.0"])
853 .output()
854 .unwrap();
855
856 let cfg = config_with("dep", git_dep(&url, Some("v1.0.0"), None, None));
857 let base = std::env::temp_dir().join(format!(
858 "sdkt-sync-tag-{}",
859 std::time::SystemTime::now()
860 .duration_since(std::time::UNIX_EPOCH)
861 .unwrap()
862 .as_nanos()
863 ));
864 let _ = std::fs::remove_dir_all(&base);
865 std::fs::create_dir_all(&base).unwrap();
866 write_lock_with(
868 &base,
869 vec![DependencyLock {
870 name: "dep".to_string(),
871 source: "git".to_string(),
872 git_url: url.clone(),
873 commit_sha: v1,
874 ..Default::default()
875 }],
876 );
877
878 let rep = plan_updates(&base, &cfg).unwrap();
879 assert_eq!(rep.changes.len(), 1);
880 assert_eq!(rep.changes[0].status, UpdateStatus::Updated);
881 assert_eq!(rep.changes[0].new_commit, v2);
882 let _ = std::fs::remove_dir_all(&base);
883 let _ = std::fs::remove_dir_all(&src);
884 }
885
886 #[test]
887 fn plan_branch_update_detected() {
888 let src = make_repo();
889 let url = src.to_string_lossy().to_string();
890 git_cmd(&src)
892 .args(["branch", "-M", "main"])
893 .output()
894 .unwrap();
895 let old = head(&src);
896 commit(&src, b"pub fn answer() -> u32 { 43 }");
897 let new = head(&src);
898
899 let cfg = config_with("dep", git_dep(&url, None, Some("main"), None));
900 let base = std::env::temp_dir().join(format!(
901 "sdkt-sync-branch-{}",
902 std::time::SystemTime::now()
903 .duration_since(std::time::UNIX_EPOCH)
904 .unwrap()
905 .as_nanos()
906 ));
907 let _ = std::fs::remove_dir_all(&base);
908 std::fs::create_dir_all(&base).unwrap();
909 write_lock_with(
910 &base,
911 vec![DependencyLock {
912 name: "dep".to_string(),
913 source: "git".to_string(),
914 git_url: url.clone(),
915 commit_sha: old,
916 ..Default::default()
917 }],
918 );
919
920 let rep = plan_updates(&base, &cfg).unwrap();
921 assert_eq!(rep.changes[0].status, UpdateStatus::Updated);
922 assert_eq!(rep.changes[0].new_commit, new);
923 let _ = std::fs::remove_dir_all(&base);
924 let _ = std::fs::remove_dir_all(&src);
925 }
926
927 #[test]
928 fn plan_unchanged_when_lock_matches() {
929 let src = make_repo();
930 let url = src.to_string_lossy().to_string();
931 git_cmd(&src).args(["tag", "v1.0.0"]).output().unwrap();
932 let v1 = head(&src);
933
934 let cfg = config_with("dep", git_dep(&url, Some("v1.0.0"), None, None));
935 let base = std::env::temp_dir().join(format!(
936 "sdkt-sync-unch-{}",
937 std::time::SystemTime::now()
938 .duration_since(std::time::UNIX_EPOCH)
939 .unwrap()
940 .as_nanos()
941 ));
942 let _ = std::fs::remove_dir_all(&base);
943 std::fs::create_dir_all(&base).unwrap();
944 write_lock_with(
945 &base,
946 vec![DependencyLock {
947 name: "dep".to_string(),
948 source: "git".to_string(),
949 git_url: url.clone(),
950 commit_sha: v1,
951 ..Default::default()
952 }],
953 );
954
955 let rep = plan_updates(&base, &cfg).unwrap();
956 assert_eq!(rep.changes[0].status, UpdateStatus::Unchanged);
957 let _ = std::fs::remove_dir_all(&base);
958 let _ = std::fs::remove_dir_all(&src);
959 }
960
961 #[test]
962 fn plan_missing_lock_is_error() {
963 let src = make_repo();
964 let url = src.to_string_lossy().to_string();
965 let cfg = config_with("dep", git_dep(&url, Some("v1.0.0"), None, None));
966 let base = std::env::temp_dir().join(format!(
967 "sdkt-sync-nolock-{}",
968 std::time::SystemTime::now()
969 .duration_since(std::time::UNIX_EPOCH)
970 .unwrap()
971 .as_nanos()
972 ));
973 let _ = std::fs::remove_dir_all(&base);
974 std::fs::create_dir_all(&base).unwrap();
975 let err = plan_updates(&base, &cfg).unwrap_err();
976 assert!(matches!(err, SyncError::MissingLock));
977 let _ = std::fs::remove_dir_all(&base);
978 let _ = std::fs::remove_dir_all(&src);
979 }
980
981 #[test]
982 fn version_resolver_picks_highest_satisfying() {
983 let tags = vec![
985 ("v1.0.0".to_string(), "c1".to_string()),
986 ("v1.5.0".to_string(), "c2".to_string()),
987 ("v2.0.0".to_string(), "c3".to_string()),
988 ("latest".to_string(), "c9".to_string()),
989 ("not-semver".to_string(), "cX".to_string()),
990 ];
991 let (tag, commit) =
993 crate::package::best_version_match(&tags, ">=1.0, <2").expect("should satisfy");
994 assert_eq!(tag, "v1.5.0");
995 assert_eq!(commit, "c2");
996 let (tag, _) = crate::package::best_version_match(&tags, "=2.0.0").expect("exact");
998 assert_eq!(tag, "v2.0.0");
999 assert!(
1001 crate::package::best_version_match(&tags, ">=3.0").is_none(),
1002 "constraint >=3.0 must be unsatisfied"
1003 );
1004 }
1005
1006 #[test]
1007 fn plan_version_update_detected() {
1008 let src = make_repo();
1009 let url = src.to_string_lossy().to_string();
1010 git_cmd(&src).args(["tag", "v1.0.0"]).output().unwrap();
1012 let v1 = head(&src);
1013 commit(&src, b"pub fn answer() -> u32 { 43 }");
1014 git_cmd(&src).args(["tag", "v1.5.0"]).output().unwrap();
1015 let v15 = head(&src);
1016 commit(&src, b"pub fn answer() -> u32 { 44 }");
1017 git_cmd(&src).args(["tag", "v2.0.0"]).output().unwrap();
1018
1019 let cfg = config_with("dep", git_dep_version(&url, ">=1.0, <2"));
1021 let base = std::env::temp_dir().join(format!(
1022 "sdkt-sync-ver-{}-{}",
1023 std::process::id(),
1024 std::time::SystemTime::now()
1025 .duration_since(std::time::UNIX_EPOCH)
1026 .unwrap()
1027 .as_nanos()
1028 ));
1029 let _ = std::fs::remove_dir_all(&base);
1030 std::fs::create_dir_all(&base).unwrap();
1031 write_lock_with(
1032 &base,
1033 vec![DependencyLock {
1034 name: "dep".to_string(),
1035 source: "git".to_string(),
1036 git_url: url.clone(),
1037 commit_sha: v1,
1038 ..Default::default()
1039 }],
1040 );
1041
1042 let rep = plan_updates(&base, &cfg).unwrap();
1043 assert_eq!(rep.changes.len(), 1);
1044 assert_eq!(rep.changes[0].status, UpdateStatus::Updated);
1045 assert_eq!(rep.changes[0].new_commit, v15);
1046 assert_eq!(rep.changes[0].resolved_tag.as_deref(), Some("v1.5.0"));
1047 assert!(rep.changes[0].detail.contains("update-available"));
1048 let _ = std::fs::remove_dir_all(&base);
1049 let _ = std::fs::remove_dir_all(&src);
1050 }
1051
1052 #[test]
1053 fn plan_version_constraint_unsatisfied() {
1054 let src = make_repo();
1055 let url = src.to_string_lossy().to_string();
1056 git_cmd(&src).args(["tag", "v1.0.0"]).output().unwrap();
1057
1058 let cfg = config_with("dep", git_dep_version(&url, ">=3.0"));
1060 let base = std::env::temp_dir().join(format!(
1061 "sdkt-sync-verbad-{}-{}",
1062 std::process::id(),
1063 std::time::SystemTime::now()
1064 .duration_since(std::time::UNIX_EPOCH)
1065 .unwrap()
1066 .as_nanos()
1067 ));
1068 let _ = std::fs::remove_dir_all(&base);
1069 std::fs::create_dir_all(&base).unwrap();
1070 write_lock_with(
1071 &base,
1072 vec![DependencyLock {
1073 name: "dep".to_string(),
1074 source: "git".to_string(),
1075 git_url: url.clone(),
1076 commit_sha: String::new(),
1077 ..Default::default()
1078 }],
1079 );
1080
1081 let rep = plan_updates(&base, &cfg).unwrap();
1082 assert_eq!(rep.changes.len(), 1);
1083 assert_eq!(rep.changes[0].status, UpdateStatus::Constraint);
1084 assert!(rep.changes[0].detail.contains("unsatisfied"));
1085 let _ = std::fs::remove_dir_all(&base);
1086 let _ = std::fs::remove_dir_all(&src);
1087 }
1088
1089 #[test]
1090 fn plan_version_up_to_date_when_locked() {
1091 let src = make_repo();
1092 let url = src.to_string_lossy().to_string();
1093 git_cmd(&src).args(["tag", "v1.0.0"]).output().unwrap();
1094 git_cmd(&src).args(["tag", "v1.5.0"]).output().unwrap();
1095 let v15 = head(&src);
1097
1098 let cfg = config_with("dep", git_dep_version(&url, ">=1.0, <2"));
1100 let base = std::env::temp_dir().join(format!(
1101 "sdkt-sync-verok-{}-{}",
1102 std::process::id(),
1103 std::time::SystemTime::now()
1104 .duration_since(std::time::UNIX_EPOCH)
1105 .unwrap()
1106 .as_nanos()
1107 ));
1108 let _ = std::fs::remove_dir_all(&base);
1109 std::fs::create_dir_all(&base).unwrap();
1110 write_lock_with(
1111 &base,
1112 vec![DependencyLock {
1113 name: "dep".to_string(),
1114 source: "git".to_string(),
1115 git_url: url.clone(),
1116 commit_sha: v15,
1117 ..Default::default()
1118 }],
1119 );
1120
1121 let rep = plan_updates(&base, &cfg).unwrap();
1122 assert_eq!(rep.changes[0].status, UpdateStatus::Unchanged);
1123 assert!(rep.changes[0].detail.contains("up-to-date"));
1124 let _ = std::fs::remove_dir_all(&base);
1125 let _ = std::fs::remove_dir_all(&src);
1126 }
1127
1128 #[test]
1129 fn apply_updates_refreshes_cache_and_lock() {
1130 let src = make_repo();
1131 let url = src.to_string_lossy().to_string();
1132 git_cmd(&src).args(["tag", "v1.0.0"]).output().unwrap();
1133 let v1 = head(&src);
1134 commit(&src, b"pub fn answer() -> u32 { 43 }");
1135 let v2 = head(&src);
1136 git_cmd(&src)
1137 .args(["tag", "-f", "v1.0.0"])
1138 .output()
1139 .unwrap();
1140
1141 let cfg = config_with("dep", git_dep(&url, Some("v1.0.0"), None, None));
1142 let base = std::env::temp_dir().join(format!(
1143 "sdkt-sync-apply-{}",
1144 std::time::SystemTime::now()
1145 .duration_since(std::time::UNIX_EPOCH)
1146 .unwrap()
1147 .as_nanos()
1148 ));
1149 let _ = std::fs::remove_dir_all(&base);
1150 std::fs::create_dir_all(&base).unwrap();
1151 let cache_key = crate::fetch::git_cache_key(&git_dep(&url, Some("v1.0.0"), None, None));
1154 let checkout = base.join(".sdkt-cache").join("git").join(&cache_key);
1155 let _ = std::fs::remove_dir_all(&checkout);
1156 std::fs::create_dir_all(&checkout).unwrap();
1157 let clone = Command::new(crate::fetch::git_bin())
1159 .args(["clone", "-q", &url, checkout.to_string_lossy().as_ref()])
1160 .output()
1161 .unwrap();
1162 assert!(clone.status.success(), "seed clone failed");
1163 write_lock_with(
1164 &base,
1165 vec![DependencyLock {
1166 name: "dep".to_string(),
1167 source: "git".to_string(),
1168 git_url: url.clone(),
1169 commit_sha: v1,
1170 cache_location: checkout.display().to_string(),
1171 ..Default::default()
1172 }],
1173 );
1174
1175 let (rep, lock) = apply_updates(&base, &cfg).unwrap();
1176 assert_eq!(rep.changes[0].status, UpdateStatus::Updated);
1177 assert_eq!(rep.changes[0].new_commit, v2);
1178 let updated = lock.dependencies.iter().find(|d| d.name == "dep").unwrap();
1180 assert_eq!(updated.commit_sha, v2);
1181 let cur = head(&checkout);
1183 assert_eq!(cur, v2);
1184 let _ = std::fs::remove_dir_all(&base);
1185 let _ = std::fs::remove_dir_all(&src);
1186 }
1187
1188 #[test]
1189 fn plan_errors_on_unknown_reference() {
1190 let src = make_repo();
1191 let url = src.to_string_lossy().to_string();
1192 let cfg = config_with("dep", git_dep(&url, Some("v9.9.9"), None, None));
1193 let base = std::env::temp_dir().join(format!(
1194 "sdkt-sync-unknown-{}",
1195 std::time::SystemTime::now()
1196 .duration_since(std::time::UNIX_EPOCH)
1197 .unwrap()
1198 .as_nanos()
1199 ));
1200 let _ = std::fs::remove_dir_all(&base);
1201 std::fs::create_dir_all(&base).unwrap();
1202 write_lock_with(
1203 &base,
1204 vec![DependencyLock {
1205 name: "dep".to_string(),
1206 source: "git".to_string(),
1207 git_url: url.clone(),
1208 ..Default::default()
1209 }],
1210 );
1211 let rep = plan_updates(&base, &cfg).unwrap();
1212 assert_eq!(rep.changes[0].status, UpdateStatus::Error);
1213 let _ = std::fs::remove_dir_all(&base);
1214 let _ = std::fs::remove_dir_all(&src);
1215 }
1216}