Skip to main content

sdkt_core/
sync.rs

1//! Package update & synchronization layer for the Soroban DevKit (sdkt), .
2//!
3//! After `sdkt package fetch` materializes dependencies into `.sdkt-cache` and
4//! records them in `sdkt.lock`, `sdkt package update` reconciles the lock with
5//! what is currently available upstream and refreshes the on-disk checkouts.
6//!
7//! Design goals (reuse, no duplication):
8//! * `GitFetcher` / `git_cache_key` / `git_bin` do all git I/O — this module
9//!   only decides *what* to fetch and *what* to write.
10//! * `lock_dependencies_resolved` (in `lock.rs`) is the single writer of
11//!   `DependencyLock` entries, shared with `sdkt package fetch`.
12//! * `read_lock` / `write_lock` keep `sdkt.lock` stable; contract artifacts and
13//!   deploy order are never touched.
14//! * `validate_manifest` is reused for manifest sanity before any work.
15//!
16//! Reference semantics:
17//! * `rev` — immutable, already pinned; never updated.
18//! * `tag` — resolve the tag's current commit; if it differs from the lock,
19//!   update.
20//! * `branch` — fetch the latest branch head; if it differs from the lock,
21//!   update.
22//!
23//! Offline-first: available commits are resolved via `git ls-remote` against
24//! the declared `git` URL. For a local-path "remote" (used in tests and for
25//! local mirrors) this needs no network; reaching a real remote does, and a
26//! failure is reported as a clear network/git error rather than a panic.
27
28use 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/// Outcome state for a single dependency during an update plan/apply.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum UpdateStatus {
37    /// No newer commit is available; lock already matches.
38    Unchanged,
39    /// A newer commit was available and applied (or would be, in check/dry-run).
40    Updated,
41    /// A `rev` dependency — immutably pinned, never updated.
42    Pinned,
43    /// A `version` constraint () is declared but no remote tag satisfies it.
44    Constraint,
45    /// Could not be resolved (missing cache, unknown reference, git error, ...).
46    Error,
47}
48
49/// One dependency's update result.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct UpdateChange {
52    /// Dependency name (key under `[dependencies]`).
53    pub name: String,
54    /// Source kind (`local` / `git`).
55    pub source: String,
56    /// What happened to this dependency.
57    pub status: UpdateStatus,
58    /// Previously locked commit SHA (empty if none / not a git dep).
59    pub old_commit: String,
60    /// Newly resolved/available commit SHA (empty for local / pinned-with-no-remote).
61    pub new_commit: String,
62    /// Human-readable detail (old→new, reason, or error message).
63    pub detail: String,
64    /// The tag selected by the version resolver (when a `version` constraint
65    /// matched). Empty otherwise. Carried so `apply_updates` can fetch the exact
66    /// resolved tag without re-querying the remote.
67    pub resolved_tag: Option<String>,
68}
69
70/// Aggregate report for `sdkt package update` (check / dry-run / apply).
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct UpdateReport {
73    /// How many dependencies were inspected.
74    pub checked: usize,
75    /// How many were (or would be) updated.
76    pub updated: usize,
77    /// How many were unchanged.
78    pub unchanged: usize,
79    /// Per-dependency detail.
80    pub changes: Vec<UpdateChange>,
81}
82
83/// Errors that abort an update before producing a partial result.
84#[derive(Debug)]
85pub enum SyncError {
86    /// The `.sdkt.toml` manifest is invalid.
87    InvalidManifest(String),
88    /// No `sdkt.lock` exists (run `sdkt package fetch` first).
89    MissingLock,
90    /// The system `git` executable is required but unavailable.
91    GitUnavailable,
92    /// A `git` operation exited non-zero.
93    Git {
94        args: String,
95        status: Option<i32>,
96        stderr: String,
97    },
98    /// A `git` remote reference (tag/branch) does not exist.
99    UnknownReference(String),
100    /// A network failure while contacting a git remote.
101    NetworkFailure(String),
102    /// A `branch` dependency's local cache checkout is in a detached HEAD
103    /// (not on the declared branch).
104    DetachedBranch(String),
105    /// A general I/O or lock-write failure.
106    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
152/// Convert a `FetchError` (from `GitFetcher`) into a `SyncError`.
153impl 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
185/// The `.sdkt-cache` root for a given project base directory.
186///
187/// Every cache-resolution path (`git_checkout`, `compute_dependency_integrity`,
188/// `verify_dependencies`) keys off `base.join(".sdkt-cache")`, so this helper is
189/// the single source of truth for that location. `base` is the project/workspace
190/// root (`.` when running the CLI from a project), never the process cwd — using
191/// cwd would drop the cache inside the current directory even when that is a
192/// library crate (e.g. while running `cargo test` from `crates/sdkt-core`).
193fn cache_root(base: &Path) -> PathBuf {
194    base.join(".sdkt-cache")
195}
196
197/// The cache checkout path for a git dependency (reuses the fetcher's keying).
198fn 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
221/// Resolve the commit SHA a `git ls-remote` reports for a reference.
222///
223/// * `rev` deps return the rev verbatim (already pinned).
224/// * `tag` → `refs/tags/<tag>`
225/// * `branch` → `refs/heads/<branch>`
226///
227/// Offline-safe for local-path remotes. Real remotes require network; failures
228/// are mapped to [`SyncError::UnknownReference`] / [`SyncError::NetworkFailure`].
229fn resolve_available_commit(
230    url: &str,
231    tag: &Option<String>,
232    branch: &Option<String>,
233    rev: &Option<String>,
234) -> Result<String, SyncError> {
235    // rev is immutable and already pinned — the "available" commit is itself.
236    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    // Probe git availability first for a clear error.
252    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            // Format: "<sha>\t<ref>".
270            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
296/// List all `(tag, commit)` pairs advertised by a git remote's tags.
297///
298/// Reuses `git_bin` (the same fetcher primitive as `resolve_available_commit`).
299/// Offline for local-path remotes; reaches a real remote only when the declared
300/// `git` URL is network-backed. A failure (git unavailable, unknown host) maps
301/// to a clear `SyncError`. Tags whose ref points at a non-commit object (e.g.
302/// annotated-tag peel targets are resolved; lightweight tags are commits) are
303/// returned with their peeled commit SHA.
304fn list_remote_tags(url: &str) -> Result<Vec<(String, String)>, SyncError> {
305    // Probe git availability first for a clear error.
306    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                // Format: "<sha>\trefs/tags/<tag>" (annotated tags also emit
322                // "<sha>^{}\trefs/tags/<tag>" peeled lines — skip those).
323                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
361/// Resolve a `version` constraint () against a remote's tags.
362///
363/// Returns `Some((tag, commit))` for the highest satisfying tag, or `None` when
364/// no tag satisfies the constraint. Pure selection logic lives in
365/// `crate::package::best_version_match`; this wrapper only fetches the tag list
366/// (via `list_remote_tags`) and forwards it, so the I/O and the comparator stay
367/// in their respective single owners.
368pub(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
376/// Whether a git cache checkout for `dep` exists on disk under `.sdkt-cache`.
377fn 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
390/// Compute the read-only update plan: what *would* change, without touching the
391/// cache or the lock. This powers both `--check` and `--dry-run`.
392///
393/// Returns an error only for hard, fatal conditions (invalid manifest, missing
394/// lock, git unavailable). Per-dependency problems (missing cache, unknown
395/// reference, network) are recorded as `UpdateChange`s with `status = Error`
396/// so the caller can report them without panicking.
397pub 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        // rev: immutably pinned.
431        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        // — version-constraint resolution: a `version` constraint with no
445        // explicit tag/branch/rev resolves against the remote's tags.
446        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        // Resolve what the remote currently offers.
515        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                // Missing cache is a fatal-ish condition for check/dry-run:
528                // we cannot fetch in those modes, so report it clearly.
529                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
574/// Apply updates: fetch/refresh every git dependency whose available commit
575/// differs from the lock (tag/branch only; `rev` is skipped as pinned), then
576/// rewrite `sdkt.lock` with the new commit, cache location, and integrity —
577/// preserving contract artifacts and deploy order.
578///
579/// Local path deps and pinned `rev` deps are left untouched. Returns the
580/// report plus the rewritten lock file (also written to disk).
581pub 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    // Only git tag/branch deps flagged as Updated need a real fetch.
591    let mut fetched: Vec<FetchOutcome> = Vec::new();
592    // Cache is rooted at `base` (the project/workspace root), consistent with
593    // every other cache-resolution path — NOT the process cwd, which would drop
594    // the cache inside a library crate when running unit tests from there.
595    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        // — a `version`-constrained dep resolved to a specific tag during
609        // planning. Override the dep's ref with the resolved tag so the existing
610        // `GitFetcher` (which requires exactly one ref) fetches the right commit,
611        // without re-implementing fetch logic.
612        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        // Detached-branch guard for branch deps: if the cache checkout exists
625        // but is not on the declared branch, flag it rather than silently moving.
626        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        // Refresh the cached checkout (force: pull latest). rev is never here.
644        let outcome = fetcher.fetch(&change.name, &fetch_dep, true)?;
645        fetched.push(outcome);
646    }
647
648    // Rebuild dependency lock entries, overlaying freshly fetched outcomes.
649    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
660/// Fold a list of changes into a summarized [`UpdateReport`].
661fn 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    // --- local git repo helpers (offline, no network) ------------------------
691
692    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    /// A `git` dependency constrained only by a `version` semver constraint
792    /// () — no explicit `tag`/`branch`/`rev`.
793    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    // --- tests ---------------------------------------------------------------
802
803    #[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        // Move the remote so a branch/tag WOULD see a change, but rev stays put.
809        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        // First commit is tagged v1.0.0 (from make_repo via tag below).
845        git_cmd(&src).args(["tag", "v1.0.0"]).output().unwrap();
846        let v1 = head(&src);
847        // Move HEAD forward (the tag still points at v1, but we simulate the tag
848        // being moved by re-tagging after a new commit).
849        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        // Lock records the OLD tag commit.
867        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        // Ensure a `main` branch exists (default branch may vary).
891        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        // Pure selection logic (no git): reuse crate::package::best_version_match.
984        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        // Caret/range picks highest 1.x.
992        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        // Exact match.
997        let (tag, _) = crate::package::best_version_match(&tags, "=2.0.0").expect("exact");
998        assert_eq!(tag, "v2.0.0");
999        // No match available.
1000        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        // Tags v1.0.0, v1.5.0, v2.0.0 (v2.0.0 is outside the constraint).
1011        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        // Constraint ">=1.0, <2" should resolve to v1.5.0 (highest 1.x).
1020        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        // Constraint ">=3.0" cannot be satisfied by the available tags.
1059        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        // After tagging v1.5.0, HEAD is the commit that tag points at.
1096        let v15 = head(&src);
1097
1098        // Lock already records v1.5.0 (the constraint's best) → unchanged.
1099        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        // Simulate a prior fetch: write lock with the OLD commit and a cache dir
1152        // that contains the OLD checkout (so apply_updates fetches the new one).
1153        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        // Clone the OLD revision into the cache so apply_updates has something to update.
1158        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        // Lock must now record the new commit.
1179        let updated = lock.dependencies.iter().find(|d| d.name == "dep").unwrap();
1180        assert_eq!(updated.commit_sha, v2);
1181        // Cache checkout must now be at the new commit.
1182        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}