1use 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#[derive(Debug, Clone)]
29pub struct RegistrySpec {
30 pub url: String,
31 pub r#ref: String,
32 pub section: Option<String>,
33}
34
35#[derive(Debug, Clone)]
37pub struct RegistryLoadResult {
38 pub config: AvailableConfig,
39 pub resolved_commit: String,
40}
41
42pub 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 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 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 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 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 r.section
117 .clone()
118 .or_else(|| r.sections.as_ref().and_then(|s| s.first().cloned()))
120 })
121}
122
123pub 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(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
170fn 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 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
214fn 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 resolved_commit: "local".to_string(),
243 })
244}
245
246fn 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(®istry_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 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(®istry_path, spec, logger)?;
277 }
278 } else {
279 logger.debug("[registry] no cache — cloning repository");
281 clone_registry(®istry_path, spec)?;
282 if !is_version_tag(&spec.r#ref) {
283 logger.debug(&format!("[registry] checking out ref: {}", spec.r#ref));
284 checkout_registry(®istry_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(®istry_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
328pub 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 &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
409fn 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
430fn is_version_tag(r#ref: &str) -> bool {
434 r#ref.starts_with('v')
436 && r#ref.len() > 1
437 && r#ref[1..].chars().next().map_or(false, char::is_numeric)
438}
439
440fn has_remote_changes(repo_path: &Path) -> Result<bool, Error> {
445 let _ = run_git(&["fetch", "--depth", "1", "origin"], repo_path);
447
448 let local_head = get_resolved_commit(repo_path)?;
450
451 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 Ok(local_head != remote_head)
462 }
463 _ => {
464 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
507fn 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 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 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}