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