Skip to main content

ralph/cli/
config.rs

1//! `ralph config ...` command group: Clap types and handler.
2
3use anyhow::{Context, Result};
4use clap::{Args, Subcommand, ValueEnum};
5use std::env;
6
7use crate::{agent, config, contracts};
8
9/// Output format for `config show` command.
10#[derive(Debug, Clone, Copy, Default, ValueEnum)]
11pub enum ConfigShowFormat {
12    /// YAML output (human-readable, default).
13    #[default]
14    #[value(alias = "text", alias = "yml")]
15    Yaml,
16
17    /// JSON output for scripting and tooling.
18    Json,
19}
20
21/// Arguments for the `ralph config show` command.
22#[derive(Args, Debug, Clone, Copy)]
23pub struct ConfigShowArgs {
24    /// Output format.
25    #[arg(long, value_enum, default_value = "yaml")]
26    pub format: ConfigShowFormat,
27}
28
29pub fn handle_config(cmd: ConfigCommand) -> Result<()> {
30    match cmd {
31        ConfigCommand::Show(args) => {
32            let resolved = config::resolve_from_cwd()?;
33            match args.format {
34                ConfigShowFormat::Json => {
35                    let rendered = serde_json::to_string_pretty(&resolved.config)?;
36                    println!("{rendered}");
37                }
38                ConfigShowFormat::Yaml => {
39                    let rendered = serde_yaml::to_string(&resolved.config)?;
40                    print!("{rendered}");
41                }
42            }
43        }
44        ConfigCommand::Paths => {
45            let resolved = config::resolve_from_cwd()?;
46            println!("repo_root: {}", resolved.repo_root.display());
47            println!("queue: {}", resolved.queue_path.display());
48            println!("done: {}", resolved.done_path.display());
49            if let Some(path) = resolved.global_config_path.as_ref() {
50                println!("global_config: {}", path.display());
51            } else {
52                println!("global_config: (unavailable)");
53            }
54            if let Some(path) = resolved.project_config_path.as_ref() {
55                println!("project_config: {}", path.display());
56            } else {
57                println!("project_config: (unavailable)");
58            }
59        }
60        ConfigCommand::Schema => {
61            let schema = schemars::schema_for!(contracts::Config);
62            println!("{}", serde_json::to_string_pretty(&schema)?);
63        }
64        ConfigCommand::Profiles(profiles_args) => {
65            handle_profiles(profiles_args)?;
66        }
67        ConfigCommand::Trust(trust_args) => {
68            let cwd = env::current_dir().context("resolve current working directory")?;
69            let repo_root = config::find_repo_root(&cwd);
70            match trust_args.command {
71                ConfigTrustCommand::Init => {
72                    config::initialize_repo_trust_file(&repo_root)?;
73                }
74            }
75        }
76    }
77    Ok(())
78}
79
80fn handle_profiles(args: ConfigProfilesArgs) -> Result<()> {
81    let resolved = config::resolve_from_cwd()?;
82
83    match args.command {
84        ConfigProfilesCommand::List => {
85            let names = agent::all_profile_names(resolved.config.profiles.as_ref());
86
87            if names.is_empty() {
88                println!("No profiles configured.");
89                println!(
90                    "Define profiles under the `profiles` key in .ralph/config.jsonc or ~/.config/ralph/config.jsonc."
91                );
92                return Ok(());
93            }
94
95            println!("Available profiles:");
96            for name in names {
97                if let Some(patch) =
98                    agent::resolve_profile_patch(&name, resolved.config.profiles.as_ref())
99                {
100                    let details = format_profile_summary(&patch);
101                    println!("  {} - {}", name, details);
102                } else {
103                    println!("  {}", name);
104                }
105            }
106        }
107        ConfigProfilesCommand::Show { name } => {
108            let name = name.trim();
109            if name.is_empty() {
110                anyhow::bail!("Profile name cannot be empty");
111            }
112
113            match agent::resolve_profile_patch(name, resolved.config.profiles.as_ref()) {
114                Some(patch) => {
115                    println!("Profile: {}", name);
116                    if resolved
117                        .config
118                        .profiles
119                        .as_ref()
120                        .is_some_and(|p| p.contains_key(name))
121                    {
122                        println!("Source: config");
123                    }
124                    println!();
125                    let rendered = serde_yaml::to_string(&patch)?;
126                    print!("{}", rendered);
127                }
128                None => {
129                    let names = agent::all_profile_names(resolved.config.profiles.as_ref());
130                    if names.is_empty() {
131                        anyhow::bail!(
132                            "Unknown profile: {name:?}. No profiles are configured. Define profiles under the `profiles` key in .ralph/config.jsonc or ~/.config/ralph/config.jsonc."
133                        );
134                    }
135                    anyhow::bail!(
136                        "Unknown profile: {name:?}. Available configured profiles: {}",
137                        names.into_iter().collect::<Vec<_>>().join(", ")
138                    );
139                }
140            }
141        }
142    }
143    Ok(())
144}
145
146/// Format a profile patch as a summary string.
147fn format_profile_summary(patch: &contracts::AgentConfig) -> String {
148    let mut parts = Vec::new();
149
150    if let Some(runner) = &patch.runner {
151        parts.push(format!("runner={}", runner.as_str()));
152    }
153    if let Some(model) = &patch.model {
154        parts.push(format!("model={}", model.as_str()));
155    }
156    if let Some(phases) = patch.phases {
157        parts.push(format!("phases={}", phases));
158    }
159    if let Some(effort) = &patch.reasoning_effort {
160        parts.push(format!("effort={}", format_reasoning_effort(*effort)));
161    }
162    if let Some(mode) = &patch.claude_permission_mode {
163        parts.push(format!(
164            "claude_permission_mode={}",
165            format_claude_permission_mode(*mode)
166        ));
167    }
168    if let Some(mode) = patch.effective_approval_mode() {
169        parts.push(format!(
170            "approval_mode={}",
171            format_runner_approval_mode(mode)
172        ));
173    }
174    if let Some(mode) = patch.effective_git_publish_mode() {
175        parts.push(format!("git_publish_mode={}", mode.as_str()));
176    }
177
178    if parts.is_empty() {
179        "no overrides".to_string()
180    } else {
181        parts.join(", ")
182    }
183}
184
185fn format_claude_permission_mode(mode: contracts::ClaudePermissionMode) -> &'static str {
186    match mode {
187        contracts::ClaudePermissionMode::AcceptEdits => "accept_edits",
188        contracts::ClaudePermissionMode::BypassPermissions => "bypass_permissions",
189    }
190}
191
192fn format_runner_approval_mode(mode: contracts::RunnerApprovalMode) -> &'static str {
193    match mode {
194        contracts::RunnerApprovalMode::Default => "default",
195        contracts::RunnerApprovalMode::AutoEdits => "auto_edits",
196        contracts::RunnerApprovalMode::Yolo => "yolo",
197        contracts::RunnerApprovalMode::Safe => "safe",
198    }
199}
200
201fn format_reasoning_effort(effort: contracts::ReasoningEffort) -> &'static str {
202    match effort {
203        contracts::ReasoningEffort::Low => "low",
204        contracts::ReasoningEffort::Medium => "medium",
205        contracts::ReasoningEffort::High => "high",
206        contracts::ReasoningEffort::XHigh => "xhigh",
207    }
208}
209
210/// Arguments for `ralph config trust ...`.
211#[derive(Args, Debug)]
212pub struct ConfigTrustArgs {
213    #[command(subcommand)]
214    pub command: ConfigTrustCommand,
215}
216
217#[derive(Subcommand, Debug, Clone, Copy)]
218pub enum ConfigTrustCommand {
219    /// Create or update `.ralph/trust.jsonc` so execution-sensitive project settings are allowed.
220    Init,
221}
222
223#[derive(Args)]
224#[command(
225    about = "Inspect and manage Ralph configuration",
226    after_long_help = "Examples:\n  ralph config show\n  ralph config show --format json\n  ralph config paths\n  ralph config schema\n  ralph config trust init\n  ralph config profiles list\n  ralph config profiles show fast-local"
227)]
228pub struct ConfigArgs {
229    #[command(subcommand)]
230    pub command: ConfigCommand,
231}
232
233#[derive(Subcommand)]
234pub enum ConfigCommand {
235    /// Show the resolved Ralph configuration.
236    #[command(
237        after_long_help = "Examples:\n  ralph config show\n  ralph config show --format json\n  ralph config show --format yaml"
238    )]
239    Show(ConfigShowArgs),
240    /// Print paths to the queue, done archive, and config files.
241    #[command(after_long_help = "Example:\n  ralph config paths")]
242    Paths,
243    /// Print the JSON schema for the configuration.
244    #[command(after_long_help = "Example:\n  ralph config schema")]
245    Schema,
246    /// List and inspect configuration profiles.
247    #[command(
248        after_long_help = "Examples:\n  ralph config profiles list\n  ralph config profiles show fast-local\n  ralph config profiles show deep-review"
249    )]
250    Profiles(ConfigProfilesArgs),
251    /// Manage repo-local execution trust (`.ralph/trust.jsonc`).
252    #[command(after_long_help = "Examples:\n  ralph config trust init")]
253    Trust(ConfigTrustArgs),
254}
255
256/// Arguments for the `ralph config profiles` command.
257#[derive(Args)]
258pub struct ConfigProfilesArgs {
259    #[command(subcommand)]
260    pub command: ConfigProfilesCommand,
261}
262
263/// Subcommands for `ralph config profiles`.
264#[derive(Subcommand)]
265pub enum ConfigProfilesCommand {
266    /// List available configured profiles.
267    List,
268    /// Show one configured profile (effective patch).
269    Show { name: String },
270}
271
272#[cfg(test)]
273mod profile_summary_tests {
274    use super::*;
275    use crate::agent;
276
277    #[test]
278    fn builtin_safe_profile_summary_includes_safety_and_publish() {
279        let patch = agent::resolve_profile_patch("safe", None).expect("builtin safe");
280        let summary = format_profile_summary(&patch);
281        assert!(summary.contains("approval_mode=safe"), "{summary}");
282        assert!(
283            summary.contains("claude_permission_mode=accept_edits"),
284            "{summary}"
285        );
286        assert!(summary.contains("git_publish_mode=off"), "{summary}");
287    }
288
289    #[test]
290    fn builtin_power_user_profile_summary_includes_safety_and_publish() {
291        let patch = agent::resolve_profile_patch("power-user", None).expect("builtin power-user");
292        let summary = format_profile_summary(&patch);
293        assert!(summary.contains("approval_mode=yolo"), "{summary}");
294        assert!(
295            summary.contains("claude_permission_mode=bypass_permissions"),
296            "{summary}"
297        );
298        assert!(
299            summary.contains("git_publish_mode=commit_and_push"),
300            "{summary}"
301        );
302    }
303}