Skip to main content

ralph_workflow/app/
config_init.rs

1//! Configuration loading and agent registry initialization.
2//!
3//! This module handles:
4//! - Loading configuration from the unified config file (~/.config/ralph-workflow.toml)
5//! - Applying environment variable and CLI overrides
6//! - Selecting default agents from fallback chains
7//! - Loading agent registry data from unified config
8//! - Fetching and caching `OpenCode` API catalog for dynamic provider/model resolution
9//!
10//! # Dependency Injection
11//!
12//! The [`initialize_config_with`] function accepts both a [`CatalogLoader`] and a
13//! [`ConfigEnvironment`] for full dependency injection. This enables testing without
14//! network calls or environment variable dependencies.
15
16mod registry;
17
18use crate::agents::opencode_api::{CatalogLoader, RealCatalogLoader};
19use crate::agents::ConfigSource;
20use crate::cli::{
21    apply_args_to_config, handle_check_config_with, handle_extended_help,
22    handle_generate_completion, handle_init_global_with, handle_init_local_config_with,
23    handle_list_work_guides, handle_smart_init_with, Args,
24};
25use crate::config::{
26    loader, unified_config_path, Config, ConfigEnvironment, RealConfigEnvironment,
27};
28use crate::logger::Colors;
29use crate::logger::Logger;
30use std::path::PathBuf;
31
32use crate::agents::AgentRegistry;
33use registry::{apply_default_agents, load_agent_registry, resolve_agent_config_source_path};
34
35/// Result of configuration initialization.
36pub struct ConfigInitResult {
37    /// The loaded configuration with CLI args applied.
38    pub config: Config,
39    /// The agent registry with merged configs.
40    pub registry: AgentRegistry,
41    /// The resolved path to the unified config file (for diagnostics/errors).
42    pub config_path: PathBuf,
43    /// Sources from which agent configs were loaded.
44    pub config_sources: Vec<ConfigSource>,
45    /// Description of config sources searched when resolving required agents.
46    pub agent_resolution_sources: AgentResolutionSources,
47}
48
49/// Describes which config sources were consulted for agent resolution.
50#[derive(Debug, Clone)]
51pub struct AgentResolutionSources {
52    /// Path to local config if local config lookup was active in this run.
53    pub local_config_path: Option<PathBuf>,
54    /// Path to global config if global config lookup was active in this run.
55    pub global_config_path: Option<PathBuf>,
56    /// Whether built-in defaults were part of resolution.
57    pub built_in_defaults: bool,
58}
59
60impl AgentResolutionSources {
61    /// Render a user-facing source list for diagnostics.
62    #[must_use]
63    pub fn describe_searched_sources(&self) -> String {
64        let sources: Vec<String> = [
65            self.local_config_path
66                .as_ref()
67                .map(|path| format!("local config ({})", path.display())),
68            self.global_config_path
69                .as_ref()
70                .map(|path| format!("global config ({})", path.display())),
71            self.built_in_defaults
72                .then(|| "built-in defaults".to_string()),
73        ]
74        .into_iter()
75        .flatten()
76        .collect();
77
78        if sources.is_empty() {
79            "none".to_string()
80        } else {
81            sources.join(", ")
82        }
83    }
84}
85
86/// Initializes configuration and agent registry.
87///
88/// This function performs the following steps:
89/// 1. Loads config from unified config file (~/.config/ralph-workflow.toml)
90/// 2. Applies environment variable overrides
91/// 3. Applies CLI arguments to config
92/// 4. Handles --list-work-guides, --init/--init-global if set
93/// 5. Loads agent registry from built-ins + unified config
94/// 6. Selects default agents from fallback chains
95///
96/// # Arguments
97///
98/// * `args` - The parsed CLI arguments
99/// * `colors` - Color configuration for output
100/// * `logger` - Logger for info/warning messages
101///
102/// # Returns
103///
104/// Returns `Ok(Some(result))` on success, `Ok(None)` if an early exit was triggered
105/// (e.g., --init, --list-templates), or an error if initialization fails.
106///
107/// # Errors
108///
109/// Returns error if the operation fails.
110pub fn initialize_config(
111    args: &Args,
112    colors: Colors,
113    logger: &Logger,
114) -> anyhow::Result<Option<ConfigInitResult>> {
115    initialize_config_with(
116        args,
117        colors,
118        logger,
119        &RealCatalogLoader::default(),
120        &RealConfigEnvironment,
121    )
122}
123
124/// Initializes configuration and agent registry with full dependency injection.
125///
126/// This is the same as [`initialize_config`] but accepts both a [`CatalogLoader`]
127/// and a [`ConfigEnvironment`] for full dependency injection. This enables testing
128/// without network calls or environment variable dependencies.
129///
130/// # Arguments
131///
132/// * `args` - The parsed CLI arguments
133/// * `colors` - Color configuration for output
134/// * `logger` - Logger for info/warning messages
135#[expect(clippy::print_stderr, reason = "CLI error output to user")]
136#[expect(clippy::print_stdout, reason = "CLI help output to user")]
137/// * `catalog_loader` - Loader for the `OpenCode` API catalog
138/// * `path_resolver` - Resolver for configuration file paths
139///
140/// # Returns
141///
142/// Returns `Ok(Some(result))` on success, `Ok(None)` if an early exit was triggered
143/// (e.g., --init, --list-templates), or an error if initialization fails.
144///
145/// # Errors
146///
147/// Returns error if the operation fails.
148pub fn initialize_config_with<L: CatalogLoader, P: ConfigEnvironment>(
149    args: &Args,
150    colors: Colors,
151    logger: &Logger,
152    catalog_loader: &L,
153    path_resolver: &P,
154) -> anyhow::Result<Option<ConfigInitResult>> {
155    // Load configuration from unified config file (with env overrides)
156    // Uses the provided path_resolver for filesystem operations instead of std::fs directly
157    let (config, unified, warnings) =
158        match loader::load_config_from_path_with_env(args.config.as_deref(), path_resolver) {
159            Ok(result) => result,
160            Err(e) => {
161                // Config validation failed - display error and exit
162                // Per requirements: Ralph refuses to start pipeline if ANY config file has errors
163                eprintln!("{}", e.format_errors());
164                return Err(anyhow::anyhow!("Configuration validation failed"));
165            }
166        };
167
168    // Display any deprecation warnings from config loading
169    warnings.iter().for_each(|warning| logger.warn(warning));
170
171    let config_path = args
172        .config
173        .clone()
174        .or_else(unified_config_path)
175        .unwrap_or_else(|| PathBuf::from("~/.config/ralph-workflow.toml"));
176
177    // Apply CLI arguments to config
178    let config = apply_args_to_config(args, config, colors);
179
180    // Handle --generate-completion flag: generate shell completion script and exit
181    if let Some(shell) = args.completion.generate_completion {
182        if handle_generate_completion(shell) {
183            return Ok(None);
184        }
185    }
186
187    // Handle --extended-help / --man flag: display extended help and exit.
188    // If combined with --list-work-guides, show both to reduce surprises.
189    if args.recovery.extended_help {
190        handle_extended_help();
191        if args.work_guide_list.list_work_guides {
192            println!();
193            let _ = handle_list_work_guides(colors);
194        }
195        return Ok(None);
196    }
197
198    // Handle --list-work-guides / --list-templates flag: display available Work Guides and exit
199    if args.work_guide_list.list_work_guides && handle_list_work_guides(colors) {
200        return Ok(None);
201    }
202
203    // Handle smart --init flag: intelligently determine what to initialize
204    if args.unified_init.init.is_some()
205        && handle_smart_init_with(
206            args.unified_init.init.as_deref(),
207            args.unified_init.force_init,
208            colors,
209            path_resolver,
210        )?
211    {
212        return Ok(None);
213    }
214
215    // Handle --init-config flag: explicit config creation and exit
216    if args.unified_init.init_config && handle_init_global_with(colors, path_resolver)? {
217        return Ok(None);
218    }
219
220    // Handle --init-global flag: create unified config if it doesn't exist and exit
221    if args.unified_init.init_global && handle_init_global_with(colors, path_resolver)? {
222        return Ok(None);
223    }
224
225    // Handle --init-local-config flag: create local project config and exit
226    if args.unified_init.init_local_config
227        && handle_init_local_config_with(colors, path_resolver, args.unified_init.force_init)?
228    {
229        return Ok(None);
230    }
231
232    // Handle --check-config flag: validate and display effective settings
233    if args.unified_init.check_config
234        && handle_check_config_with(colors, path_resolver, args.debug_verbosity.debug)?
235    {
236        return Ok(None);
237    }
238
239    let local_config_path = path_resolver.local_config_path();
240    let global_config_path = args
241        .config
242        .clone()
243        .or_else(|| path_resolver.unified_config_path());
244
245    let agent_resolution_sources = AgentResolutionSources {
246        local_config_path: if args.config.is_none() {
247            local_config_path.clone()
248        } else {
249            None
250        },
251        global_config_path,
252        built_in_defaults: true,
253    };
254
255    // Initialize agent registry with built-in defaults + unified config.
256    let config_source_path = resolve_agent_config_source_path(
257        config_path.as_path(),
258        args.config.as_deref(),
259        local_config_path.as_deref(),
260        path_resolver,
261    );
262    let (registry, config_sources) = load_agent_registry(
263        unified.as_ref(),
264        config_source_path.as_path(),
265        catalog_loader,
266    )?;
267
268    // Apply default agents from fallback chains
269    let config = apply_default_agents(&config, &registry);
270
271    Ok(Some(ConfigInitResult {
272        config,
273        registry,
274        config_path,
275        config_sources,
276        agent_resolution_sources,
277    }))
278}
279
280#[cfg(test)]
281mod tests {
282    use super::{initialize_config_with, AgentResolutionSources};
283    use crate::agents::opencode_api::{
284        ApiCatalog, CacheError, CatalogLoader, DEFAULT_CACHE_TTL_SECONDS,
285    };
286    use crate::cli::Args;
287    use crate::config::MemoryConfigEnvironment;
288    use crate::logger::{Colors, Logger};
289    use clap::Parser;
290    use std::collections::HashMap;
291    use std::path::PathBuf;
292
293    struct StaticCatalogLoader;
294
295    impl CatalogLoader for StaticCatalogLoader {
296        fn load(&self) -> Result<ApiCatalog, CacheError> {
297            Ok(ApiCatalog {
298                providers: HashMap::new(),
299                models: HashMap::new(),
300                cached_at: None,
301                ttl_seconds: DEFAULT_CACHE_TTL_SECONDS,
302            })
303        }
304    }
305
306    #[test]
307    fn test_explicit_config_does_not_report_local_source() {
308        let args = Args::try_parse_from(["ralph", "--config", "/test/config/ralph-workflow.toml"])
309            .expect("args should parse");
310        let logger = Logger::new(Colors::new());
311        let env = MemoryConfigEnvironment::new()
312            .with_unified_config_path("/test/config/ralph-workflow.toml")
313            .with_local_config_path("/test/repo/.agent/ralph-workflow.toml")
314            .with_file(
315                "/test/repo/.agent/ralph-workflow.toml",
316                "[agent_chain]\ndeveloper = [\"codex\"]\n",
317            );
318
319        let result =
320            initialize_config_with(&args, Colors::new(), &logger, &StaticCatalogLoader, &env)
321                .expect("initialization should succeed")
322                .expect("normal execution should return config init result");
323
324        assert!(
325            result.config_sources.is_empty(),
326            "with explicit --config and no explicit file present, local config should not be consulted"
327        );
328        assert_eq!(result.agent_resolution_sources.local_config_path, None);
329        assert_eq!(
330            result.agent_resolution_sources.global_config_path,
331            Some(PathBuf::from("/test/config/ralph-workflow.toml"))
332        );
333    }
334
335    #[test]
336    fn test_agent_resolution_sources_include_local_when_no_explicit_config() {
337        let args = Args::try_parse_from(["ralph"]).expect("args should parse");
338        let logger = Logger::new(Colors::new());
339        let env = MemoryConfigEnvironment::new()
340            .with_unified_config_path("/test/config/ralph-workflow.toml")
341            .with_local_config_path("/test/repo/.agent/ralph-workflow.toml");
342
343        let result =
344            initialize_config_with(&args, Colors::new(), &logger, &StaticCatalogLoader, &env)
345                .expect("initialization should succeed")
346                .expect("normal execution should return config init result");
347
348        assert_eq!(
349            result.agent_resolution_sources.local_config_path,
350            Some(PathBuf::from("/test/repo/.agent/ralph-workflow.toml"))
351        );
352        assert_eq!(
353            result.agent_resolution_sources.global_config_path,
354            Some(PathBuf::from("/test/config/ralph-workflow.toml"))
355        );
356        assert!(result.agent_resolution_sources.built_in_defaults);
357    }
358
359    #[test]
360    fn test_agent_resolution_sources_exclude_local_with_explicit_config() {
361        let args = Args::try_parse_from(["ralph", "--config", "/custom/path.toml"])
362            .expect("args should parse");
363        let logger = Logger::new(Colors::new());
364        let env = MemoryConfigEnvironment::new()
365            .with_unified_config_path("/test/config/ralph-workflow.toml")
366            .with_local_config_path("/test/repo/.agent/ralph-workflow.toml");
367
368        let result =
369            initialize_config_with(&args, Colors::new(), &logger, &StaticCatalogLoader, &env)
370                .expect("initialization should succeed")
371                .expect("normal execution should return config init result");
372
373        assert_eq!(result.agent_resolution_sources.local_config_path, None);
374        assert_eq!(
375            result.agent_resolution_sources.global_config_path,
376            Some(PathBuf::from("/custom/path.toml"))
377        );
378    }
379
380    #[test]
381    fn test_agent_resolution_sources_description_omits_missing_sources() {
382        let sources = AgentResolutionSources {
383            local_config_path: None,
384            global_config_path: Some(PathBuf::from("/custom/path.toml")),
385            built_in_defaults: true,
386        };
387
388        assert_eq!(
389            sources.describe_searched_sources(),
390            "global config (/custom/path.toml), built-in defaults"
391        );
392    }
393}