Skip to main content

commit_wizard/engine/models/runtime/
mod.rs

1use std::path::PathBuf;
2
3use crate::engine::{
4    LoggerTrait,
5    config::{
6        BaseConfig,
7        env::build_env_config,
8        registry::{RegistrySpec, load_registry, resolve_registry_spec},
9        resolver::{resolve_global_configs, resolve_project_configs},
10    },
11    constants::resolve_project_config_path,
12    models::policy::Policy,
13};
14
15pub mod mode;
16pub mod options;
17pub mod resolution;
18pub use options::*;
19pub use resolution::*;
20
21#[derive(Debug, Clone)]
22pub struct Runtime {
23    // the mode we are running in, determined at runtime based on args and environment
24    mode: mode::RunMode,
25    // options that affect how we run, determined at runtime based on args and environment
26    options: RuntimeOptions,
27    // paths and environment information, determined at runtime
28    paths: RuntimePaths,
29    // the config options we have available, determined at runtime
30    resolution: RuntimeResolution,
31}
32
33impl Default for Runtime {
34    fn default() -> Self {
35        Self::new()
36    }
37}
38
39impl Runtime {
40    pub fn output_config(&self) -> scriba::Config {
41        scriba::Config {
42            interactive: matches!(self.mode, mode::RunMode::Interactive),
43            format: self.options.output_format(),
44            color: self.options.output_color(),
45            level: self.options.log_level(),
46            auto_yes: self.options.auto_yes(),
47        }
48    }
49
50    // -------------------------
51    // getters
52    // -------------------------
53
54    pub fn is_ci(&self) -> bool {
55        matches!(self.mode, mode::RunMode::Ci)
56    }
57
58    pub fn is_non_interactive(&self) -> bool {
59        matches!(self.mode, mode::RunMode::NonInteractive)
60    }
61
62    pub fn is_interactive(&self) -> bool {
63        matches!(self.mode, mode::RunMode::Interactive)
64    }
65
66    pub fn mode(&self) -> &mode::RunMode {
67        &self.mode
68    }
69
70    pub fn options(&self) -> &RuntimeOptions {
71        &self.options
72    }
73
74    pub fn options_mut(&mut self) -> &mut RuntimeOptions {
75        &mut self.options
76    }
77
78    pub fn cwd(&self) -> &PathBuf {
79        &self.paths.cwd
80    }
81
82    pub fn in_git_repo(&self) -> bool {
83        self.paths.in_git_repo
84    }
85
86    pub fn repo_root(&self) -> &PathBuf {
87        self.paths.repo_root.as_ref().unwrap_or(&self.paths.cwd)
88    }
89
90    pub fn global_paths(&self) -> &RuntimeGlobalPaths {
91        &self.paths.global
92    }
93
94    pub fn global_config_path(&self) -> &PathBuf {
95        &self.paths.global.config
96    }
97
98    pub fn global_cache_path(&self) -> &PathBuf {
99        &self.paths.global.cache
100    }
101
102    pub fn global_state_path(&self) -> &PathBuf {
103        &self.paths.global.state
104    }
105
106    pub fn state_file_path(&self) -> PathBuf {
107        use crate::engine::constants::STATE_FILE_NAME;
108        self.paths.global.state.join(STATE_FILE_NAME)
109    }
110
111    pub fn sources(&self) -> &AvailableConfigOptions {
112        &self.resolution.sources
113    }
114
115    pub fn sources_mut(&mut self) -> &mut AvailableConfigOptions {
116        &mut self.resolution.sources
117    }
118
119    pub fn config(&self) -> Option<&ResolvedConfig> {
120        self.resolution.config.as_ref()
121    }
122
123    pub fn config_mut(&mut self) -> Option<&mut ResolvedConfig> {
124        self.resolution.config.as_mut()
125    }
126
127    pub fn policy(&self) -> &Policy {
128        &self.resolution.policy
129    }
130
131    pub fn policy_mut(&mut self) -> &mut Policy {
132        &mut self.resolution.policy
133    }
134
135    // -------------------------
136    // setters
137    // -------------------------
138
139    pub fn set_mode(&mut self, mode: mode::RunMode) -> &mut Self {
140        self.mode = mode;
141        self
142    }
143
144    pub fn set_dry_run(&mut self, dry_run: bool) -> &mut Self {
145        self.options.set_dry_run(dry_run);
146        self
147    }
148
149    pub fn set_auto_yes(&mut self, auto_yes: bool) -> &mut Self {
150        self.options.set_auto_yes(auto_yes);
151        self
152    }
153
154    pub fn set_force(&mut self, force: bool) -> &mut Self {
155        self.options.set_force(force);
156        self
157    }
158
159    pub fn set_output_envelope(&mut self, envelope: scriba::EnvelopeMode) -> &mut Self {
160        self.options.set_output_envelope(envelope);
161        self
162    }
163
164    pub fn set_output_format(&mut self, format: scriba::Format) -> &mut Self {
165        self.options.set_output_format(format);
166        self
167    }
168
169    pub fn set_output_color(&mut self, color: scriba::ColorMode) -> &mut Self {
170        self.options.set_output_color(color);
171        self
172    }
173
174    pub fn set_log_level(&mut self, level: scriba::Level) -> &mut Self {
175        self.options.set_log_level(level);
176        self
177    }
178
179    pub fn set_cwd(&mut self, cwd: PathBuf) -> &mut Self {
180        // needs to resolve to full path to ensure consistency when comparing with repo root
181        self.paths.cwd = std::fs::canonicalize(&cwd).unwrap_or_else(|e| {
182            eprintln!(
183                "[warn] Failed to canonicalize cwd {:?}: {e} — using path as-is",
184                cwd
185            );
186            cwd
187        });
188        self
189    }
190
191    pub fn set_in_git_repo(&mut self, in_git_repo: bool) -> &mut Self {
192        self.paths.in_git_repo = in_git_repo;
193        self
194    }
195
196    pub fn set_repo_root(&mut self, repo_root: PathBuf) -> &mut Self {
197        // needs to resolve to full path to ensure consistency when comparing with cwd
198        self.paths.repo_root = Some(std::fs::canonicalize(&repo_root).unwrap_or_else(|e| {
199            eprintln!(
200                "[warn] Failed to canonicalize repo_root {:?}: {e} — using path as-is",
201                repo_root
202            );
203            repo_root
204        }));
205        self
206    }
207
208    pub fn set_sources(&mut self, sources: AvailableConfigOptions) -> &mut Self {
209        self.resolution.sources = sources;
210        self
211    }
212
213    pub fn set_config(&mut self, config: ResolvedConfig) -> &mut Self {
214        self.resolution.config = Some(config);
215        self
216    }
217
218    pub fn set_policy(&mut self, policy: Policy) -> &mut Self {
219        self.resolution.policy = policy;
220        self
221    }
222
223    pub fn new() -> Self {
224        Self {
225            mode: mode::RunMode::Interactive,
226            options: RuntimeOptions::new(),
227            paths: RuntimePaths::new(),
228            resolution: RuntimeResolution {
229                sources: AvailableConfigOptions {
230                    cli_config: None,
231                    env_config: None,
232                    repo_config: None,
233                    global_config: None,
234                    registries: Vec::new(),
235                },
236                config: None,
237                policy: Policy::default(),
238            },
239        }
240    }
241
242    pub fn resolve_cli_source(&mut self, logger: Option<&dyn LoggerTrait>) {
243        if let Some(path) = self.explicit_config_path().cloned() {
244            let (base_config, rules_config) = resolve_project_configs(&path, logger);
245            self.resolution.sources.cli_config =
246                Some(resolve_available_config(base_config, rules_config));
247        }
248    }
249
250    pub fn resolve_repo_source(&mut self, logger: &dyn LoggerTrait) {
251        let cwd = self.cwd().clone();
252        let repo_root = self.repo_root().clone();
253        let in_git = self.in_git_repo();
254
255        let msg = format!(
256            "Resolving repo config: cwd={:?}, repo_root={:?}, in_git_repo={}",
257            cwd, repo_root, in_git
258        );
259        logger.debug(&msg);
260
261        if let Some(path) = resolve_project_config_path(&cwd, Some(&repo_root), in_git, None) {
262            let msg = format!("Found config path: {:?}", path);
263            logger.debug(&msg);
264            let (base_config, rules_config) = resolve_project_configs(&path, Some(logger));
265            let msg = format!(
266                "Config loaded: base={}, rules={}",
267                base_config.is_some(),
268                rules_config.is_some()
269            );
270            logger.debug(&msg);
271            self.resolution.sources.repo_config =
272                Some(resolve_available_config(base_config, rules_config));
273        } else {
274            logger.info("No project config found");
275        }
276    }
277
278    pub fn resolve_global_source(&mut self) {
279        let (base_config, rules_config) = resolve_global_configs();
280        self.resolution.sources.global_config =
281            Some(resolve_available_config(base_config, rules_config));
282    }
283
284    pub fn resolve_env_source(&mut self) {
285        if let Some(base) = build_env_config() {
286            self.resolution.sources.env_config = Some(resolve_available_config(Some(base), None));
287        }
288    }
289
290    /// Resolve and load all available registries from every config layer,
291    /// marking the one selected via precedence (CLI > ENV > repo > global) as active.
292    pub fn resolve_registry_source(&mut self, logger: &dyn LoggerTrait) {
293        let partial_base = self.build_partial_config_for_registry();
294
295        let cli_url = self.explicit_registry().map(String::to_owned);
296        let cli_ref = self.explicit_registry_ref().map(String::to_owned);
297        let cli_section = self.explicit_registry_section().map(String::to_owned);
298
299        // Determine which registry is ACTIVE (via precedence: CLI > ENV > repo > global)
300        let active_spec = resolve_registry_spec(
301            cli_url.as_deref(),
302            cli_ref.as_deref(),
303            cli_section.as_deref(),
304            Some(&partial_base),
305        );
306
307        // Resolve rule references in the active spec URL so it can be compared with the
308        // collected specs (which have already had their URLs resolved from rules).
309        let partial_rules = self.build_partial_rules_for_registry();
310        let active_spec = active_spec.map(|mut spec| {
311            if let Some(rules) = &partial_rules
312                && let Ok(resolved) = rules.resolve_string(&spec.url)
313            {
314                spec.url = resolved;
315            }
316            spec
317        });
318
319        // Collect and deduplicate all registry specs from all config layers
320        let mut all_specs = self.collect_all_registry_specs();
321
322        // If the active spec was supplied via CLI or ENV and doesn't appear in the pool
323        // (e.g. the user passed --registry for an ad-hoc URL not in the config), inject
324        // it so it gets loaded and marked active.
325        if let Some(ref a) = active_spec {
326            let already_present = all_specs.iter().any(|(_, s)| {
327                // Match on URL + ref only; section is resolved separately for active registry
328                s.url == a.url && s.r#ref == a.r#ref
329            });
330            if !already_present {
331                all_specs.push(("cli".to_string(), a.clone()));
332            }
333        }
334
335        // Load each registry and add it to the pool
336        let cache_dir = self.global_cache_path().clone();
337        let state_file_path = self.state_file_path();
338
339        for (name, spec) in all_specs {
340            // For the active registry, use the fully-resolved active_spec (with section)
341            // For others, use their spec as-is (which may have no section)
342            let spec_to_load = if let Some(ref a) = active_spec {
343                if spec.url == a.url && spec.r#ref == a.r#ref {
344                    a.clone()
345                } else {
346                    spec.clone()
347                }
348            } else {
349                spec.clone()
350            };
351
352            let is_active = active_spec
353                .as_ref()
354                .is_some_and(|a| a.url == spec.url && a.r#ref == spec.r#ref);
355
356            // Build the stable registry id: url#ref or url#ref/section
357            let registry_id = match &spec_to_load.section {
358                Some(section) => {
359                    format!("{}##{}/{}", spec_to_load.url, spec_to_load.r#ref, section)
360                }
361                None => format!("{}##{}", spec_to_load.url, spec_to_load.r#ref),
362            };
363
364            match load_registry(&spec_to_load, &cache_dir, &state_file_path, logger) {
365                Ok(result) => {
366                    let status = if is_active { "[ACTIVE]" } else { "[available]" };
367                    logger.debug(&format!(
368                        "Registry loaded: url={}, ref={}, section={} {status}",
369                        spec_to_load.url,
370                        spec_to_load.r#ref,
371                        spec_to_load.section.as_deref().unwrap_or("(root)")
372                    ));
373
374                    // Save state for active registry
375                    if is_active {
376                        use crate::engine::config::registry::registry_cache_path;
377                        let cache_path =
378                            registry_cache_path(&spec_to_load.url, &spec_to_load.r#ref, &cache_dir);
379
380                        use crate::engine::models::state::{AppState, RegistryState};
381                        let mut state = AppState::new();
382                        state.registry = Some(RegistryState::new(
383                            Some(name.clone()),
384                            spec_to_load.url.clone(),
385                            spec_to_load.r#ref.clone(),
386                            spec_to_load.section.clone(),
387                            result.resolved_commit.clone(),
388                            cache_path,
389                        ));
390
391                        if let Err(e) = state.save(&state_file_path) {
392                            logger.warn(&format!("Failed to save registry state: {e}"));
393                        }
394                    }
395
396                    self.resolution.sources.registries.push(RegistryOptions {
397                        id: registry_id,
398                        tag: name,
399                        url: spec_to_load.url,
400                        r#ref: spec_to_load.r#ref,
401                        section: spec_to_load.section,
402                        config: Some(result.config),
403                        sections: None,
404                        is_active,
405                    });
406                }
407                Err(e) => logger.error(&format!("Registry load failed ({name}): {e}")),
408            }
409        }
410    }
411
412    /// Build a merged BaseConfig from all layers except CLI (used to resolve the active registry
413    /// before the full config is available).
414    fn build_partial_config_for_registry(&self) -> BaseConfig {
415        let global = self
416            .resolution
417            .sources
418            .global_config
419            .as_ref()
420            .and_then(|c| c.base.clone());
421        let env = self
422            .resolution
423            .sources
424            .env_config
425            .as_ref()
426            .and_then(|c| c.base.clone());
427        let repo = self
428            .resolution
429            .sources
430            .repo_config
431            .as_ref()
432            .and_then(|c| c.base.clone());
433        let cli = self
434            .resolution
435            .sources
436            .cli_config
437            .as_ref()
438            .and_then(|c| c.base.clone());
439        let base = global.unwrap_or_else(BaseConfig::empty);
440        let base = if let Some(r) = repo {
441            r.merge(base)
442        } else {
443            base
444        };
445        let base = if let Some(e) = env {
446            e.merge(base)
447        } else {
448            base
449        };
450        if let Some(c) = cli {
451            c.merge(base)
452        } else {
453            base
454        }
455    }
456
457    /// Return the highest-precedence rules available before registries are loaded.
458    /// Used to resolve @rules.* references in the active registry spec URL.
459    fn build_partial_rules_for_registry(&self) -> Option<crate::engine::config::RulesConfig> {
460        self.resolution
461            .sources
462            .cli_config
463            .as_ref()
464            .and_then(|c| c.rules.clone())
465            .or_else(|| {
466                self.resolution
467                    .sources
468                    .repo_config
469                    .as_ref()
470                    .and_then(|c| c.rules.clone())
471            })
472            .or_else(|| {
473                self.resolution
474                    .sources
475                    .global_config
476                    .as_ref()
477                    .and_then(|c| c.rules.clone())
478            })
479    }
480
481    /// Collect all uniquely-identifiable registry specs from every config layer (global + repo).
482    /// Deduplicates by (name, url) to avoid loading the same registry twice.
483    /// Resolves rule references in URLs (e.g., @rules.vars.cw_registry).
484    fn collect_all_registry_specs(&self) -> Vec<(String, RegistrySpec)> {
485        let mut specs: Vec<(String, RegistrySpec)> = Vec::new();
486
487        for available_config in [
488            self.resolution.sources.global_config.as_ref(),
489            self.resolution.sources.repo_config.as_ref(),
490        ]
491        .into_iter()
492        .flatten()
493        {
494            if let Some(cfg) = available_config.base.as_ref() {
495                for (name, reg) in cfg.registries_map() {
496                    if let Some(url) = reg.url {
497                        // Resolve rule references in URL if rules are available.
498                        // Failure to resolve MUST error (SRS §4.3) — no silent fallback.
499                        let resolved_url = if let Some(rules) = &available_config.rules {
500                            match rules.resolve_string(&url) {
501                                Ok(s) => s,
502                                Err(_) => {
503                                    // Rule reference could not be resolved — skip this registry
504                                    // with a note; error will surface clearly if this was the active one.
505                                    continue;
506                                }
507                            }
508                        } else {
509                            url.clone()
510                        };
511
512                        // Only include explicit section field, NOT sections array
513                        // (sections array is only for documentation/validation, not for pool deduplication)
514                        specs.push((
515                            name,
516                            RegistrySpec {
517                                url: resolved_url,
518                                r#ref: reg.r#ref.unwrap_or_else(|| "HEAD".to_string()),
519                                section: reg.section,
520                            },
521                        ));
522                    }
523                }
524            }
525        }
526
527        // Deduplicate: keep first occurrence of each (name, url) pair
528        let mut seen = std::collections::HashSet::new();
529        specs.retain(|(name, spec)| seen.insert((name.clone(), spec.url.clone())));
530        specs
531    }
532
533    pub fn resolve_available_sources(&mut self, logger: &dyn LoggerTrait) {
534        self.resolve_cli_source(Some(logger));
535        self.resolve_env_source();
536        self.resolve_repo_source(logger);
537        self.resolve_global_source();
538        self.resolve_registry_source(logger);
539    }
540
541    pub fn resolve_active_config(
542        &mut self,
543        logger: &dyn LoggerTrait,
544    ) -> crate::engine::error::Result<()> {
545        // Precedence rules:
546        // 1. If no config sources: use policy::default (do nothing).
547        // 2. If only global: use global as-is (no merging with defaults).
548        // 3-4. If registry/repo/cli: registry is base, repo/cli applied as overrides.
549        // 5-6. If registry/repo/cli exists: never use global or defaults as base.
550        // 7-9. Rules: cli > repo > registry > global (global only if registry has no rules).
551
552        let global_base = self
553            .resolution
554            .sources
555            .global_config
556            .as_ref()
557            .and_then(|c| c.base.clone());
558        let registry_base = self
559            .resolution
560            .sources
561            .registries
562            .iter()
563            .find(|r| r.is_active)
564            .and_then(|r| r.config.as_ref())
565            .and_then(|c| c.base.clone());
566        let repo_base = self
567            .resolution
568            .sources
569            .repo_config
570            .as_ref()
571            .and_then(|c| c.base.clone());
572        let cli_base = self
573            .resolution
574            .sources
575            .cli_config
576            .as_ref()
577            .and_then(|c| c.base.clone());
578
579        // Check if any project-level (registry/repo/cli) config exists (Rules 5-6).
580        let has_registry_repo_or_cli =
581            registry_base.is_some() || repo_base.is_some() || cli_base.is_some();
582
583        if has_registry_repo_or_cli {
584            // Rules 3-4: Registry as base, then apply repo and cli as overrides.
585            let base = {
586                let base = registry_base.unwrap_or_else(BaseConfig::empty);
587                let base = if let Some(r) = repo_base {
588                    r.merge(base)
589                } else {
590                    base
591                };
592                if let Some(c) = cli_base {
593                    c.merge(base)
594                } else {
595                    base
596                }
597            };
598
599            // Rules 7-9: Determine which rules to use.
600            // cli > repo > registry > global (global only if registry has no rules).
601            let registry_rules = self
602                .resolution
603                .sources
604                .registries
605                .iter()
606                .find(|r| r.is_active)
607                .and_then(|r| r.config.as_ref())
608                .and_then(|c| c.rules.clone());
609
610            let rules = self
611                .resolution
612                .sources
613                .cli_config
614                .as_ref()
615                .and_then(|c| c.rules.clone())
616                .or_else(|| {
617                    self.resolution
618                        .sources
619                        .repo_config
620                        .as_ref()
621                        .and_then(|c| c.rules.clone())
622                })
623                .or_else(|| registry_rules.clone());
624
625            // Rule 8: Only use global rules if registry doesn't have rules.
626            let rules = if registry_rules.is_none() {
627                rules.or_else(|| {
628                    self.resolution
629                        .sources
630                        .global_config
631                        .as_ref()
632                        .and_then(|c| c.rules.clone())
633                })
634            } else {
635                rules
636            }
637            .unwrap_or_default();
638
639            // Merge rules into base: ResolvedConfig.base becomes the single source of truth
640            // with all @rules.* references resolved. Failure to resolve MUST error (SRS §4.3).
641            use crate::engine::config::resolver::merge_rules_into_base;
642            let base = merge_rules_into_base(base, &rules)?;
643
644            logger.debug(&format!(
645                "[config] resolved commit.types: {:?}",
646                base.commit
647                    .as_ref()
648                    .and_then(|c| c.types.as_ref())
649                    .map(|t| t.keys().cloned().collect::<Vec<_>>()),
650            ));
651
652            let path = self.project_config_path();
653            self.resolution.config = Some(ResolvedConfig { path, rules, base });
654            self.resolve_policy();
655        } else if global_base.is_some() {
656            // Rule 2: Only global exists, use it as-is (no merging with defaults).
657            let base = global_base.unwrap();
658            let rules = self
659                .resolution
660                .sources
661                .global_config
662                .as_ref()
663                .and_then(|c| c.rules.clone())
664                .unwrap_or_default();
665
666            use crate::engine::config::resolver::merge_rules_into_base;
667            let base = merge_rules_into_base(base, &rules)?;
668
669            logger.debug(&format!(
670                "[config] resolved commit.types: {:?}",
671                base.commit
672                    .as_ref()
673                    .and_then(|c| c.types.as_ref())
674                    .map(|t| t.keys().cloned().collect::<Vec<_>>()),
675            ));
676
677            let path = self.project_config_path();
678            self.resolution.config = Some(ResolvedConfig { path, rules, base });
679            self.resolve_policy();
680        }
681        // Rule 1: If no config sources, do nothing—policy remains at engine defaults.
682
683        Ok(())
684    }
685
686    pub fn resolve_policy(&mut self) {
687        let policy = resolve_policy(self.config());
688        self.resolution.policy = policy;
689    }
690
691    pub fn explicit_config_path(&self) -> Option<&PathBuf> {
692        self.paths.explicit_config_path.as_ref()
693    }
694
695    pub fn explicit_registry(&self) -> Option<&String> {
696        self.paths.explicit_registry.as_ref()
697    }
698
699    pub fn explicit_registry_ref(&self) -> Option<&String> {
700        self.paths.explicit_registry_ref.as_ref()
701    }
702
703    pub fn explicit_registry_section(&self) -> Option<&String> {
704        self.paths.explicit_registry_section.as_ref()
705    }
706
707    pub fn set_explicit_config_path(&mut self, path: Option<PathBuf>) -> &mut Self {
708        self.paths.explicit_config_path = path;
709        self
710    }
711
712    pub fn set_explicit_registry(&mut self, registry: Option<String>) -> &mut Self {
713        self.paths.explicit_registry = registry;
714        self
715    }
716
717    pub fn set_explicit_registry_ref(&mut self, registry_ref: Option<String>) -> &mut Self {
718        self.paths.explicit_registry_ref = registry_ref;
719        self
720    }
721
722    pub fn set_explicit_registry_section(&mut self, registry_section: Option<String>) -> &mut Self {
723        self.paths.explicit_registry_section = registry_section;
724        self
725    }
726
727    pub fn project_config_path(&self) -> Option<PathBuf> {
728        resolve_project_config_path(
729            self.cwd(),
730            Some(self.repo_root().as_path()),
731            self.in_git_repo(),
732            self.explicit_config_path().map(|p| p.as_path()),
733        )
734    }
735}