commit_wizard/engine/models/runtime/
mod.rs1use 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 mode: mode::RunMode,
25 options: RuntimeOptions,
27 paths: RuntimePaths,
29 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 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 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 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 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 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 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 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 let mut all_specs = self.collect_all_registry_specs();
321
322 if let Some(ref a) = active_spec {
326 let already_present = all_specs.iter().any(|(_, s)| {
327 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 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 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 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 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 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 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 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 let resolved_url = if let Some(rules) = &available_config.rules {
500 match rules.resolve_string(&url) {
501 Ok(s) => s,
502 Err(_) => {
503 continue;
506 }
507 }
508 } else {
509 url.clone()
510 };
511
512 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 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 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 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 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 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 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 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 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 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}