Skip to main content

commit_wizard/engine/config/
registry.rs

1/// Registry resolution (SRS §5).
2///
3/// Resolves registry configuration from CLI flags, ENV variables, and the
4/// project/global config. Supports both local-path registries and Git-URL
5/// registries (cloned/fetched into the user-level cache directory).
6///
7/// Precedence: CLI > ENV > config
8use std::{
9    path::{Path, PathBuf},
10    process::Command,
11};
12
13use crate::engine::{
14    Error, ErrorCode, LoggerTrait,
15    config::{
16        BaseConfig, RulesConfig,
17        env::get_env_registry_params,
18        resolver::{load_rules_config, load_standard_config},
19    },
20    models::runtime::resolution::{AvailableConfig, resolve_available_config},
21};
22
23// ---------------------------------------------------------------------------
24// Public types
25// ---------------------------------------------------------------------------
26
27/// Fully-resolved registry parameters.
28#[derive(Debug, Clone)]
29pub struct RegistrySpec {
30    pub url: String,
31    pub r#ref: String,
32    pub section: Option<String>,
33}
34
35/// Registry load result with config and resolved commit hash.
36#[derive(Debug, Clone)]
37pub struct RegistryLoadResult {
38    pub config: AvailableConfig,
39    pub resolved_commit: String,
40}
41
42// ---------------------------------------------------------------------------
43// Registry selection
44// ---------------------------------------------------------------------------
45
46/// Determine which registry to use, applying CLI > ENV > config precedence.
47///
48/// Returns `None` when no registry is configured from any source.
49pub fn resolve_registry_spec(
50    cli_url: Option<&str>,
51    cli_ref: Option<&str>,
52    cli_section: Option<&str>,
53    base_config: Option<&BaseConfig>,
54) -> Option<RegistrySpec> {
55    // --- URL ---
56    let env_params = get_env_registry_params();
57
58    let url = cli_url
59        .map(str::to_owned)
60        .or_else(|| env_params.url.clone())
61        .or_else(|| resolve_url_from_config(base_config))?;
62
63    // --- ref ---
64    let resolved_ref = cli_ref
65        .map(str::to_owned)
66        .or_else(|| env_params.r#ref.clone())
67        .or_else(|| resolve_ref_from_config(base_config, &url))
68        .unwrap_or_else(|| "HEAD".to_string());
69
70    // --- section ---
71    let section = cli_section
72        .map(str::to_owned)
73        .or_else(|| env_params.section.clone())
74        .or_else(|| resolve_section_from_top_level(base_config))
75        .or_else(|| resolve_section_from_named_registry(base_config, &url));
76
77    Some(RegistrySpec {
78        url,
79        r#ref: resolved_ref,
80        section,
81    })
82}
83
84fn resolve_url_from_config(config: Option<&BaseConfig>) -> Option<String> {
85    let config = config?;
86    let use_name = config.registry_use()?;
87    let registries = config.registries_map();
88    registries.get(&use_name).and_then(|r| r.url.clone())
89}
90
91fn resolve_ref_from_config(config: Option<&BaseConfig>, url: &str) -> Option<String> {
92    let config = config?;
93    let use_name = config.registry_use()?;
94    let registries = config.registries_map();
95    // Match by name or by URL
96    registries
97        .iter()
98        .find(|(name, r)| *name == &use_name || r.url.as_deref() == Some(url))
99        .and_then(|(_, r)| r.r#ref.clone())
100}
101
102fn resolve_section_from_top_level(config: Option<&BaseConfig>) -> Option<String> {
103    config?.registry.as_ref().and_then(|r| r.section.clone())
104}
105
106fn resolve_section_from_named_registry(config: Option<&BaseConfig>, url: &str) -> Option<String> {
107    let config = config?;
108    let use_name = config.registry_use()?;
109    let registries = config.registries_map();
110
111    registries
112        .iter()
113        .find(|(name, r)| *name == &use_name || r.url.as_deref() == Some(url))
114        .and_then(|(_, r)| {
115            // Try single section field first (backward compat)
116            r.section
117                .clone()
118                // Fall back to first section in sections array
119                .or_else(|| r.sections.as_ref().and_then(|s| s.first().cloned()))
120        })
121}
122
123// ---------------------------------------------------------------------------
124// Registry loading
125// ---------------------------------------------------------------------------
126
127/// Load registry configuration from the given spec.
128///
129/// Returns both the configuration and the resolved commit hash.
130///
131/// Supports:
132/// - Local paths (starts with `/`, `./`, `../`, or is an existing directory)
133///   - Loaded directly from disk, no caching or networking
134/// - Git URLs (anything else — cloned/fetched into the cache)
135///   - Implements smart-sync (SRS §10.4-10.5):
136///     - Uses cache if available (no network call)
137///     - Only fetches if remote has changes or ref is not a version tag
138///     - Version tags skip fetch entirely (assumed stable)
139pub fn load_registry(
140    spec: &RegistrySpec,
141    cache_dir: &Path,
142    state_file: &Path,
143    logger: &dyn LoggerTrait,
144) -> Result<RegistryLoadResult, Error> {
145    logger.debug(&format!(
146        "[registry] loading: url={}, ref={}, section={}, local={}",
147        spec.url,
148        spec.r#ref,
149        spec.section.as_deref().unwrap_or("(root)"),
150        is_local_path(&spec.url),
151    ));
152    if is_local_path(&spec.url) {
153        load_local_registry(spec, logger)
154    } else {
155        // Evict stale cache when the ref has changed for the same URL
156        evict_stale_cache(spec, state_file, logger);
157        load_git_registry(spec, cache_dir, logger)
158    }
159}
160
161fn is_local_path(url: &str) -> bool {
162    url.starts_with('/')
163        || url.starts_with("./")
164        || url.starts_with("../")
165        || url == "."
166        || url == ".."
167        || Path::new(url).exists()
168}
169
170// ---------------------------------------------------------------------------
171// Stale cache eviction
172// ---------------------------------------------------------------------------
173
174/// Evict the old cache directory when the registry ref has changed for the same URL.
175///
176/// Reads the previous state from state.json. If the URL matches but the ref has changed,
177/// removes the old cache directory so the new ref gets a fresh clone. This prevents
178/// ref conflicts and reclaims disk space from orphaned caches.
179fn evict_stale_cache(spec: &RegistrySpec, state_file: &Path, logger: &dyn LoggerTrait) {
180    use crate::engine::models::state::AppState;
181
182    let state = match AppState::load(state_file) {
183        Ok(s) => s,
184        Err(_) => return,
185    };
186
187    let prev = match state.registry {
188        Some(r) => r,
189        None => return,
190    };
191
192    // Only act when the URL is the same but the ref has changed
193    if prev.url != spec.url || prev.r#ref == spec.r#ref {
194        return;
195    }
196
197    logger.debug(&format!(
198        "[registry] ref changed ({} → {}) — evicting old cache",
199        prev.r#ref, spec.r#ref,
200    ));
201
202    let old_cache = PathBuf::from(&prev.cache_path);
203    if old_cache.exists() {
204        match std::fs::remove_dir_all(&old_cache) {
205            Ok(_) => logger.debug(&format!("[registry] evicted old cache: {:?}", old_cache)),
206            Err(e) => logger.warn(&format!(
207                "[registry] failed to evict old cache {:?}: {e}",
208                old_cache
209            )),
210        }
211    }
212}
213
214// ---------------------------------------------------------------------------
215// Local-path registry
216// ---------------------------------------------------------------------------
217
218fn load_local_registry(
219    spec: &RegistrySpec,
220    logger: &dyn LoggerTrait,
221) -> Result<RegistryLoadResult, Error> {
222    let base = PathBuf::from(&spec.url);
223    let dir = match &spec.section {
224        Some(section) => base.join(section),
225        None => base,
226    };
227
228    logger.debug(&format!("[registry] local path resolved to: {:?}", dir));
229
230    if !dir.exists() {
231        logger.warn(&format!("[registry] local path does not exist: {:?}", dir));
232        return Err(ErrorCode::RegistrySectionMissing
233            .error()
234            .with_context("path", dir.display().to_string()));
235    }
236
237    let config = read_registry_dir(&dir, logger)?;
238
239    Ok(RegistryLoadResult {
240        config,
241        // For local registries, we can't get a commit hash, so use a placeholder
242        resolved_commit: "local".to_string(),
243    })
244}
245
246// ---------------------------------------------------------------------------
247// Git-URL registry
248// ---------------------------------------------------------------------------
249
250fn load_git_registry(
251    spec: &RegistrySpec,
252    cache_dir: &Path,
253    logger: &dyn LoggerTrait,
254) -> Result<RegistryLoadResult, Error> {
255    let registry_id = registry_cache_id(&spec.url, &spec.r#ref);
256    let registry_path = cache_dir.join("registries").join(&registry_id);
257
258    logger.debug(&format!(
259        "[registry] cache path: {:?}, exists={}",
260        registry_path,
261        registry_path.exists()
262    ));
263
264    if registry_path.exists() {
265        // Cache hit: use it as-is for version tags (immutable), sync for HEAD/branches.
266        if is_version_tag(&spec.r#ref) {
267            logger.debug(&format!(
268                "[registry] cache hit, version tag {} — skipping sync",
269                spec.r#ref
270            ));
271        } else {
272            logger.debug(&format!(
273                "[registry] cache hit, ref={} — checking for remote changes",
274                spec.r#ref
275            ));
276            maybe_sync_registry(&registry_path, spec, logger)?;
277        }
278    } else {
279        // No cache: clone and checkout (unless it's a version tag, where --branch positions HEAD).
280        logger.debug("[registry] no cache — cloning repository");
281        clone_registry(&registry_path, spec)?;
282        if !is_version_tag(&spec.r#ref) {
283            logger.debug(&format!("[registry] checking out ref: {}", spec.r#ref));
284            checkout_registry(&registry_path, spec)?;
285        }
286    }
287
288    let dir = match &spec.section {
289        Some(section) => registry_path.join(section),
290        None => registry_path.clone(),
291    };
292
293    logger.debug(&format!(
294        "[registry] reading config from dir: {:?}, exists={}",
295        dir,
296        dir.exists()
297    ));
298
299    if !dir.exists() {
300        logger.warn(&format!(
301            "[registry] section directory not found: {:?}",
302            dir
303        ));
304        return Err(ErrorCode::RegistrySectionMissing
305            .error()
306            .with_context("section", spec.section.as_deref().unwrap_or("(root)"))
307            .with_context("path", dir.display().to_string()));
308    }
309
310    let config = read_registry_dir(&dir, logger)?;
311    let resolved_commit = get_resolved_commit(&registry_path)?;
312    logger.trace(&format!("[registry] resolved commit: {}", resolved_commit));
313
314    Ok(RegistryLoadResult {
315        config,
316        resolved_commit,
317    })
318}
319
320fn registry_cache_id(url: &str, git_ref: &str) -> String {
321    use std::collections::hash_map::DefaultHasher;
322    use std::hash::{Hash, Hasher};
323    let mut h = DefaultHasher::new();
324    format!("{url}#{git_ref}").hash(&mut h);
325    format!("{:x}", h.finish())
326}
327
328/// Returns the cache directory path for a registry, matching the path used during cloning.
329/// Section MUST NOT affect the cache path (SRS §7.2).
330pub fn registry_cache_path(url: &str, git_ref: &str, cache_dir: &Path) -> PathBuf {
331    let id = registry_cache_id(url, git_ref);
332    cache_dir.join("registries").join(id)
333}
334
335fn run_git(args: &[&str], cwd: &Path) -> Result<(), Error> {
336    let output = Command::new("git")
337        .current_dir(cwd)
338        .args(args)
339        .output()
340        .map_err(|e| {
341            ErrorCode::GitCommandFailed
342                .error()
343                .with_context("command", format!("git {}", args.join(" ")))
344                .with_context("error", e.to_string())
345        })?;
346
347    if !output.status.success() {
348        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
349        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
350        return Err(ErrorCode::RegistrySyncFailed
351            .error()
352            .with_context("command", format!("git {}", args.join(" ")))
353            .with_context("stderr", stderr)
354            .with_context("stdout", stdout));
355    }
356    Ok(())
357}
358
359fn clone_registry(dest: &Path, spec: &RegistrySpec) -> Result<(), Error> {
360    if let Some(parent) = dest.parent() {
361        std::fs::create_dir_all(parent).map_err(|e| {
362            ErrorCode::RegistrySyncFailed
363                .error()
364                .with_context("action", "create cache directory")
365                .with_context("path", parent.display().to_string())
366                .with_context("error", e.to_string())
367        })?;
368    }
369
370    let dest_str = dest.to_str().ok_or_else(|| {
371        ErrorCode::RegistrySyncFailed
372            .error()
373            .with_context("action", "clone registry")
374            .with_context("error", "cache path contains non-UTF8 characters")
375            .with_context("path", dest.display().to_string())
376    })?;
377
378    run_git(
379        // For tagged refs use --branch to fetch only that tag, keeping the clone shallow.
380        // For branch refs use --no-single-branch so all branches are available.
381        &if is_version_tag(&spec.r#ref) {
382            vec![
383                "clone",
384                "--depth",
385                "1",
386                "--branch",
387                &spec.r#ref,
388                &spec.url,
389                dest_str,
390            ]
391        } else {
392            vec![
393                "clone",
394                "--depth",
395                "1",
396                "--no-single-branch",
397                &spec.url,
398                dest_str,
399            ]
400        },
401        dest.parent().unwrap_or(Path::new(".")),
402    )
403    .map_err(|e| {
404        e.with_context("url", spec.url.clone())
405            .with_context("action", "clone registry")
406    })
407}
408
409/// Smart-sync: fetch and checkout only if the remote has changes (SRS §10.5).
410///
411/// Fetch is skipped if:
412/// - ref is a version tag (e.g., v1.0, v2.3.1) — handled by the caller before this is invoked
413/// - remote has no changes (dirty check)
414fn maybe_sync_registry(
415    dest: &Path,
416    spec: &RegistrySpec,
417    logger: &dyn LoggerTrait,
418) -> Result<(), Error> {
419    if has_remote_changes(dest)? {
420        logger.info("[registry] remote has changes — fetching");
421        fetch_registry(dest, spec)?;
422        logger.info(&format!("[registry] checking out ref: {}", spec.r#ref));
423        checkout_registry(dest, spec)?;
424    } else {
425        logger.info("[registry] no remote changes — using cache");
426    }
427    Ok(())
428}
429
430/// Check if a git ref is a version tag (e.g., v1.0, v2.3.1, v1.0-beta).
431///
432/// Returns true if ref looks like a semantic version tag.
433fn is_version_tag(r#ref: &str) -> bool {
434    // Match patterns like v1, v1.0, v1.0.0, v1.0-beta, v1.0.0-rc1
435    r#ref.starts_with('v')
436        && r#ref.len() > 1
437        && r#ref[1..].chars().next().map_or(false, char::is_numeric)
438}
439
440/// Check if a git repository has remote changes.
441///
442/// Compares local HEAD with origin/HEAD to determine if sync is needed.
443/// Returns true if remote is ahead of local (dirty).
444fn has_remote_changes(repo_path: &Path) -> Result<bool, Error> {
445    // Fetch to get latest remote refs (but don't checkout)
446    let _ = run_git(&["fetch", "--depth", "1", "origin"], repo_path);
447
448    // Get current HEAD
449    let local_head = get_resolved_commit(repo_path)?;
450
451    // Try to get remote HEAD (for current branch); if fails, assume no changes
452    let output = Command::new("git")
453        .current_dir(repo_path)
454        .args(["rev-parse", "origin/HEAD"])
455        .output();
456
457    match output {
458        Ok(o) if o.status.success() => {
459            let remote_head = String::from_utf8_lossy(&o.stdout).trim().to_string();
460            // If local and remote HEAD differ, there are changes
461            Ok(local_head != remote_head)
462        }
463        _ => {
464            // If we can't get remote HEAD, assume no changes (be conservative)
465            Ok(false)
466        }
467    }
468}
469
470fn fetch_registry(dest: &Path, spec: &RegistrySpec) -> Result<(), Error> {
471    run_git(&["fetch", "--depth", "1", "origin"], dest).map_err(|e| {
472        e.with_context("url", spec.url.clone())
473            .with_context("action", "fetch registry updates")
474    })
475}
476
477fn checkout_registry(dest: &Path, spec: &RegistrySpec) -> Result<(), Error> {
478    run_git(&["checkout", &spec.r#ref], dest).map_err(|e| {
479        e.with_context("ref", spec.r#ref.clone())
480            .with_context("action", "checkout registry ref")
481    })
482}
483
484fn get_resolved_commit(repo_path: &Path) -> Result<String, Error> {
485    let output = Command::new("git")
486        .current_dir(repo_path)
487        .args(["rev-parse", "HEAD"])
488        .output()
489        .map_err(|e| {
490            ErrorCode::GitCommandFailed
491                .error()
492                .with_context("command", "git rev-parse HEAD")
493                .with_context("error", e.to_string())
494        })?;
495
496    if !output.status.success() {
497        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
498        return Err(ErrorCode::RegistrySyncFailed
499            .error()
500            .with_context("command", "git rev-parse HEAD")
501            .with_context("stderr", stderr));
502    }
503
504    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
505}
506
507// ---------------------------------------------------------------------------
508// File reading
509// ---------------------------------------------------------------------------
510
511fn read_registry_dir(dir: &Path, logger: &dyn LoggerTrait) -> Result<AvailableConfig, Error> {
512    let config_path = dir.join("config.toml");
513    let rules_path = dir.join("rules.toml");
514
515    logger.debug(&format!(
516        "[registry] config.toml: {:?}, exists={}",
517        config_path,
518        config_path.exists()
519    ));
520    logger.debug(&format!(
521        "[registry] rules.toml: {:?}, exists={}",
522        rules_path,
523        rules_path.exists()
524    ));
525
526    // config.toml is required (SRS §5)
527    if !config_path.exists() {
528        logger.warn(&format!(
529            "[registry] config.toml missing at {:?}",
530            config_path
531        ));
532        return Err(ErrorCode::RegistryInvalid
533            .error()
534            .with_context("missing_file", config_path.display().to_string()));
535    }
536
537    let base: Option<BaseConfig> = load_standard_config(&config_path).map(|sc| {
538        use crate::engine::config::resolver::extract_config_from_standard_config;
539        extract_config_from_standard_config(&sc)
540    });
541
542    match &base {
543        Some(b) => logger.trace(&format!(
544            "[registry] config.toml parsed ok: commit.types={:?}",
545            b.commit
546                .as_ref()
547                .and_then(|c| c.types.as_ref())
548                .map(|t| t.keys().cloned().collect::<Vec<_>>()),
549        )),
550        None => {
551            logger.warn("[registry] config.toml exists but failed to parse — base config is None")
552        }
553    }
554
555    // rules.toml is optional (SRS §5)
556    let rules: Option<RulesConfig> = rules_path
557        .exists()
558        .then(|| load_rules_config(&rules_path))
559        .flatten();
560
561    match &rules {
562        Some(_) => logger.debug("[registry] rules.toml parsed ok"),
563        None if rules_path.exists() => {
564            logger.warn("[registry] rules.toml exists but failed to parse")
565        }
566        None => logger.debug("[registry] rules.toml not present (optional)"),
567    }
568
569    Ok(resolve_available_config(base, rules))
570}