Skip to main content

omni_dev/cli/
config.rs

1//! Configuration-related CLI commands.
2
3use anyhow::Result;
4use clap::{Parser, Subcommand};
5
6use crate::claude::model_config::{get_model_registry, ModelSource, MODELS_YAML};
7
8/// Configuration operations.
9#[derive(Parser)]
10pub struct ConfigCommand {
11    /// Configuration subcommand to execute.
12    #[command(subcommand)]
13    pub command: ConfigSubcommands,
14}
15
16/// Configuration subcommands.
17#[derive(Subcommand)]
18pub enum ConfigSubcommands {
19    /// AI model configuration and information.
20    Models(ModelsCommand),
21}
22
23/// Models operations.
24#[derive(Parser)]
25pub struct ModelsCommand {
26    /// Models subcommand to execute.
27    #[command(subcommand)]
28    pub command: ModelsSubcommands,
29}
30
31/// Models subcommands.
32#[derive(Subcommand)]
33pub enum ModelsSubcommands {
34    /// Shows the model catalog (merged user/project layers over the
35    /// embedded `models.yaml`), annotating each entry with its source layer.
36    Show(ShowCommand),
37}
38
39/// Show command options.
40#[derive(Parser)]
41pub struct ShowCommand {
42    /// Show only the embedded `models.yaml` verbatim, ignoring any
43    /// user/project overrides.
44    #[arg(long)]
45    pub embedded_only: bool,
46}
47
48impl ConfigCommand {
49    /// Executes the config command.
50    pub fn execute(self) -> Result<()> {
51        match self.command {
52            ConfigSubcommands::Models(models_cmd) => models_cmd.execute(),
53        }
54    }
55}
56
57impl ModelsCommand {
58    /// Executes the models command.
59    pub fn execute(self) -> Result<()> {
60        match self.command {
61            ModelsSubcommands::Show(show_cmd) => show_cmd.execute(),
62        }
63    }
64}
65
66impl ShowCommand {
67    /// Executes the show command.
68    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
81/// Serialises the merged configuration with each model and provider entry
82/// carrying a `source: embedded|user|project|override` field. Returns the
83/// rendered YAML text.
84fn 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        // Header summary mentions both layers.
156        assert!(yaml.contains("Merged model catalog"));
157        assert!(yaml.contains("embedded="));
158        assert!(yaml.contains("user="));
159
160        // Source field is present for the user-added entry…
161        assert!(yaml.contains("api_identifier: claude-custom-x"));
162        assert!(yaml.contains("source: user"));
163        // …and for embedded entries.
164        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        // execute() prints to stdout; we just confirm it does not error and
173        // that the underlying constant is what `--embedded-only` would emit.
174        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}