1use anyhow::Result;
4use clap::{Parser, Subcommand};
5
6use crate::claude::model_config::{get_model_registry, ModelSource, MODELS_YAML};
7
8#[derive(Parser)]
10pub struct ConfigCommand {
11 #[command(subcommand)]
13 pub command: ConfigSubcommands,
14}
15
16#[derive(Subcommand)]
18pub enum ConfigSubcommands {
19 Models(ModelsCommand),
21}
22
23#[derive(Parser)]
25pub struct ModelsCommand {
26 #[command(subcommand)]
28 pub command: ModelsSubcommands,
29}
30
31#[derive(Subcommand)]
33pub enum ModelsSubcommands {
34 Show(ShowCommand),
38}
39
40#[derive(Parser)]
42pub struct ShowCommand {
43 #[arg(long)]
46 pub embedded_only: bool,
47}
48
49impl ConfigCommand {
50 pub fn execute(self) -> Result<()> {
52 match self.command {
53 ConfigSubcommands::Models(models_cmd) => models_cmd.execute(),
54 }
55 }
56}
57
58impl ModelsCommand {
59 pub fn execute(self) -> Result<()> {
61 match self.command {
62 ModelsSubcommands::Show(show_cmd) => show_cmd.execute(),
63 }
64 }
65}
66
67impl ShowCommand {
68 pub fn execute(self) -> Result<()> {
70 if self.embedded_only {
71 print!("{MODELS_YAML}");
72 return Ok(());
73 }
74
75 let registry = get_model_registry();
76 let yaml = render_merged_yaml(registry.config())?;
77 print!("{yaml}");
78 Ok(())
79 }
80}
81
82fn render_merged_yaml(config: &crate::claude::model_config::ModelConfiguration) -> Result<String> {
86 let yaml = serde_yaml::to_string(config)?;
87 Ok(prepend_layer_summary(&yaml, config))
88}
89
90fn prepend_layer_summary(
91 yaml: &str,
92 config: &crate::claude::model_config::ModelConfiguration,
93) -> String {
94 let mut counts: std::collections::BTreeMap<ModelSource, usize> =
95 std::collections::BTreeMap::new();
96 for spec in &config.models {
97 *counts.entry(spec.source).or_default() += 1;
98 }
99
100 let mut header = String::new();
101 header.push_str("# Merged model catalog (project > user > embedded).\n");
102 header.push_str("# Each entry's `source:` field indicates the layer that contributed it.\n");
103 header.push_str("# Models by source: ");
104 let parts: Vec<String> = counts.iter().map(|(s, n)| format!("{s}={n}")).collect();
105 if parts.is_empty() {
106 header.push_str("(none)");
107 } else {
108 header.push_str(&parts.join(", "));
109 }
110 header.push_str(".\n#\n");
111
112 let mut out = header;
113 out.push_str(yaml);
114 out
115}
116
117#[cfg(test)]
118#[allow(clippy::unwrap_used, clippy::expect_used)]
119mod tests {
120 use super::*;
121 use crate::claude::model_config::ModelRegistry;
122 use std::io::Write;
123 use std::path::Path;
124
125 fn write(dir: &Path, name: &str, contents: &str) -> std::path::PathBuf {
126 let path = dir.join(name);
127 std::fs::File::create(&path)
128 .unwrap()
129 .write_all(contents.as_bytes())
130 .unwrap();
131 path
132 }
133
134 #[test]
135 fn rendered_yaml_includes_source_for_each_entry() {
136 let dir = tempfile::tempdir().unwrap();
137 let user = write(
138 dir.path(),
139 "user.yaml",
140 r#"
141version: "1"
142models:
143 - provider: "claude"
144 model: "Custom"
145 api_identifier: "claude-custom-x"
146 max_output_tokens: 1
147 input_context: 1
148 generation: 1.0
149 tier: "flagship"
150"#,
151 );
152
153 let registry = ModelRegistry::load_layered_from_paths(None, Some(&user), None).unwrap();
154 let yaml = render_merged_yaml(registry.config()).unwrap();
155
156 assert!(yaml.contains("Merged model catalog"));
158 assert!(yaml.contains("embedded="));
159 assert!(yaml.contains("user="));
160
161 assert!(yaml.contains("api_identifier: claude-custom-x"));
163 assert!(yaml.contains("source: user"));
164 assert!(yaml.contains("source: embedded"));
166 }
167
168 #[test]
169 fn embedded_only_flag_round_trips_embedded_yaml() {
170 let cmd = ShowCommand {
171 embedded_only: true,
172 };
173 cmd.execute().unwrap();
176 assert!(MODELS_YAML.contains("version: \"1\""));
177 }
178
179 #[test]
180 fn layer_summary_handles_empty_models() {
181 let config = crate::claude::model_config::ModelConfiguration {
182 version: Some("1".into()),
183 models: Vec::new(),
184 providers: std::collections::HashMap::new(),
185 };
186 let summary = prepend_layer_summary("", &config);
187 assert!(summary.contains("Models by source: (none)"));
188 }
189}