Skip to main content

apple_code_assistant/cli/
handler.rs

1//! CLI execution handler
2
3use std::io::{IsTerminal, Read};
4use std::path::Path;
5
6use anyhow::Result;
7
8use crate::api::{self, CodeGenClient};
9use crate::config::Config;
10use crate::conversation::ConversationManager;
11use crate::error::ConfigError;
12use crate::types::GenerateRequest;
13use crate::ui;
14use crate::utils::{self, is_supported, normalize, SUPPORTED_LANGUAGES};
15
16use super::parser::{Args, Subcommand};
17
18pub struct Handler;
19
20impl Handler {
21    pub fn new() -> Self {
22        Self
23    }
24
25    pub fn run(&self, args: &Args, config: &Config) -> Result<()> {
26        if let Some(ref sub) = args.subcommand {
27            match sub {
28                Subcommand::Config {
29                    set,
30                    get,
31                    list,
32                    reset,
33                } => {
34                    return self.run_config(config, args.config.as_deref(), *list, get.as_deref(), set.as_deref(), *reset);
35                }
36                Subcommand::Models => {
37                    println!("Available models:");
38                    println!("  apple-foundation-model (on-device, when available)");
39                    println!("  (mock client used when API not configured)");
40                }
41                Subcommand::Languages => {
42                    println!("Supported languages:");
43                    for (name, aliases) in SUPPORTED_LANGUAGES {
44                        if aliases.is_empty() {
45                            println!("  {}", name);
46                        } else {
47                            println!("  {} (aliases: {})", name, aliases.join(", "));
48                        }
49                    }
50                }
51                Subcommand::Test => {
52                    let client = api::default_client();
53                    let req = GenerateRequest {
54                        prompt: "test".to_string(),
55                        language: Some("rust".to_string()),
56                        temperature: 0.7,
57                        max_tokens: 10,
58                        model: None,
59                        context: None,
60                        tool_mode: false,
61                    };
62                    match client.generate(&req) {
63                        Ok(_) => println!("API test: OK"),
64                        Err(e) => eprintln!("API test failed: {}", e),
65                    }
66                }
67            }
68            return Ok(());
69        }
70
71        if args.interactive || args.prompt.is_none() {
72            let theme = ui::Theme::from_str(&args.theme);
73            ui::run_interactive(theme, config)?;
74            return Ok(());
75        }
76
77        if let Some(ref lang) = args.language {
78            if !is_supported(lang) {
79                return Err(anyhow::anyhow!("Unsupported language: '{}'. Use 'apple-code languages' to list supported languages.", lang));
80            }
81        }
82        let language = args
83            .language
84            .as_ref()
85            .and_then(|l| normalize(l).or(Some(l.clone())))
86            .or_else(|| config.default_language.clone());
87
88        // Conversation manager for non-interactive CLI when extending conversations.
89        let mut conversation_manager = if args.extend_conversation {
90            let mut m = ConversationManager::new();
91            // Try to load the most recent session if any.
92            if let Ok(ids) = m.list_sessions() {
93                if let Some(last) = ids.last() {
94                    let _ = m.load_session(last);
95                }
96            }
97            if m.current_session().is_none() {
98                m.create_session();
99            }
100            Some(m)
101        } else {
102            None
103        };
104
105        // Apply prompt template from config if requested or if a default_prompt is configured.
106        let prompt_template = config.resolve_prompt(args.template.as_deref());
107        let effective_model = args
108            .model
109            .clone()
110            .or_else(|| prompt_template.and_then(|(_, p)| p.model.clone()))
111            .or_else(|| config.model.clone());
112        let effective_temperature = prompt_template
113            .and_then(|(_, p)| p.temperature)
114            .unwrap_or(args.temperature);
115
116        let client = api::default_client();
117        let (request, edit_path) = if let Some(ref edit_file) = args.edit {
118            let path = Path::new(edit_file);
119            let content = utils::read_file(path).map_err(|e| anyhow::anyhow!("{}", e))?;
120
121            // Build context: main edited file content + optional explicit context + globbed files.
122            let mut context_parts: Vec<String> = Vec::new();
123            context_parts.push(format!("File content ({}):\n{}", path.display(), content));
124            if let Some(c) = args.context.as_deref() {
125                context_parts.push(c.to_string());
126            }
127            if !args.context_glob.is_empty() {
128                if let Some(glob_ctx) = read_context_globs(&args.context_glob) {
129                    context_parts.push(glob_ctx);
130                }
131            }
132            let mut context = context_parts.join("\n\n");
133            if let Some(limit) = args.char_limit {
134                if context.len() > limit as usize {
135                    context.truncate(limit as usize);
136                }
137            }
138            let request = GenerateRequest {
139                prompt: args.prompt.as_ref().unwrap().clone(),
140                language: language.clone(),
141                temperature: effective_temperature,
142                max_tokens: args.max_tokens,
143                model: effective_model.clone(),
144                context: Some(context),
145                tool_mode: args.tool_mode,
146            };
147            (request, Some(path.to_path_buf()))
148        } else {
149            // When stdin is piped (not a TTY), read it and append it directly
150            // to the prompt so the model clearly sees the input (smartcat-style).
151            let mut piped_input = String::new();
152            let has_piped_input = if !std::io::stdin().is_terminal() {
153                std::io::stdin()
154                    .read_to_string(&mut piped_input)
155                    .is_ok()
156                    && !piped_input.is_empty()
157            } else {
158                false
159            };
160
161            let mut prompt = args.prompt.as_ref().unwrap().clone();
162            if has_piped_input {
163                if let Some(limit) = args.char_limit {
164                    if piped_input.len() > limit as usize {
165                        piped_input.truncate(limit as usize);
166                    }
167                }
168                prompt.push_str("\n\n");
169                prompt.push_str(piped_input.trim_end());
170            }
171
172            // Start with any explicit --context value.
173            let mut context_parts: Vec<String> = Vec::new();
174            if let Some(ctx) = args.context.clone() {
175                context_parts.push(ctx);
176            }
177
178            // Add glob-based context files, if any.
179            if !args.context_glob.is_empty() {
180                if let Some(glob_ctx) = read_context_globs(&args.context_glob) {
181                    context_parts.push(glob_ctx);
182                }
183            }
184
185            // If we are extending a conversation, add its history as context and
186            // persist the new turn.
187            if let Some(manager) = conversation_manager.as_mut() {
188                let _ = manager.add_user_message(&prompt);
189                let history = manager.history();
190                if !history.is_empty() {
191                    let mut history_ctx = String::new();
192                    for msg in history {
193                        let role = match msg.role {
194                            crate::conversation::Role::User => "user",
195                            crate::conversation::Role::Assistant => "assistant",
196                        };
197                        history_ctx.push_str("[");
198                        history_ctx.push_str(role);
199                        history_ctx.push_str("] ");
200                        history_ctx.push_str(&msg.content);
201                        history_ctx.push('\n');
202                    }
203                    context_parts.push(history_ctx);
204                }
205            }
206
207            let context = if context_parts.is_empty() {
208                None
209            } else {
210                let mut ctx = context_parts.join("\n\n");
211                if let Some(limit) = args.char_limit {
212                    if ctx.len() > limit as usize {
213                        ctx.truncate(limit as usize);
214                    }
215                }
216                Some(ctx)
217            };
218
219            let request = GenerateRequest {
220                prompt,
221                language: language.clone(),
222                temperature: effective_temperature,
223                max_tokens: args.max_tokens,
224                model: effective_model,
225                context,
226                tool_mode: args.tool_mode,
227            };
228            (request, None)
229        };
230        let response = client.generate(&request).map_err(|e| anyhow::anyhow!("{}", e))?;
231        let code = if request.tool_mode {
232            strip_markdown_code_block(&response.code)
233        } else {
234            response.code.clone()
235        };
236        let theme = ui::Theme::from_str(&args.theme);
237        let did_edit = edit_path.is_some();
238        if let Some(ref path) = edit_path {
239            utils::write_file(path, &code).map_err(|e| anyhow::anyhow!("{}", e))?;
240            println!("Wrote {}", path.display());
241        }
242        if let Some(ref out_path) = args.output {
243            utils::write_file(Path::new(out_path), &code).map_err(|e| anyhow::anyhow!("{}", e))?;
244            println!("Wrote {}", out_path);
245        }
246        if args.copy {
247            utils::copy_to_clipboard(&code).map_err(|e| anyhow::anyhow!("Clipboard: {}", e))?;
248            println!("Copied to clipboard.");
249        }
250        if let Some(manager) = conversation_manager.as_mut() {
251            let _ = manager.add_assistant_message(&code);
252        }
253        if args.preview || (args.output.is_none() && !did_edit) {
254            if args.tool_mode {
255                // In tool mode, emit plain output suitable for piping/editor integration.
256                print!("{}", code);
257            } else {
258                if args.repeat_input && !std::io::stdin().is_terminal() {
259                    // Repeat piped input followed by the model output, smartcat-style.
260                    // We don't have the raw stdin anymore, but the effective prompt already
261                    // contains it appended; repeating the prompt before the output keeps
262                    // the behavior close enough for editor workflows.
263                    println!("{}", args.prompt.as_deref().unwrap_or_default());
264                    println!();
265                }
266                ui::print_code_preview(
267                    &code,
268                    response.language.as_deref(),
269                    theme,
270                );
271            }
272        }
273        Ok(())
274    }
275
276    fn run_config(
277        &self,
278        config: &Config,
279        config_file_override: Option<&str>,
280        list: bool,
281        get: Option<&str>,
282        set: Option<&str>,
283        reset: bool,
284    ) -> Result<()> {
285        if reset {
286            let default = Config::default();
287            default.save(config_file_override.map(Path::new))?;
288            println!("Configuration reset to defaults.");
289            return Ok(());
290        }
291        if let Some(s) = set {
292            let mut c = config.clone();
293            let (key, value) = s
294                .split_once('=')
295                .ok_or_else(|| ConfigError::Invalid("expected key=value".to_string()))?;
296            c.set(key.trim(), value.trim())?;
297            c.save(config_file_override.map(Path::new))?;
298            println!("Set {} = {}", key.trim(), value.trim());
299            return Ok(());
300        }
301        if let Some(key) = get {
302            match config.get(key) {
303                Some(v) => println!("{}", v),
304                None => return Err(ConfigError::Invalid(format!("unknown key: {}", key)).into()),
305            }
306            return Ok(());
307        }
308        if list {
309            for key in Config::keys() {
310                let val = config.get(key).unwrap_or_else(|| "<unset>".to_string());
311                println!("{} = {}", key, val);
312            }
313            if let Some(ref path) = config.config_file {
314                println!("(config file: {})", path.display());
315            }
316            return Ok(());
317        }
318        println!("Use --list, --get <key>, --set key=value, or --reset.");
319        Ok(())
320    }
321}
322
323fn read_context_globs(patterns: &[String]) -> Option<String> {
324    let mut chunks: Vec<String> = Vec::new();
325    for pattern in patterns {
326        if let Ok(paths) = glob::glob(pattern) {
327            for entry in paths.flatten() {
328                if entry.is_file() {
329                    if let Ok(content) = std::fs::read_to_string(&entry) {
330                        chunks.push(format!("=== {} ===\n{}", entry.display(), content));
331                    }
332                }
333            }
334        }
335    }
336    if chunks.is_empty() {
337        None
338    } else {
339        Some(chunks.join("\n\n"))
340    }
341}
342
343fn strip_markdown_code_block(s: &str) -> String {
344    // Find the first occurrence of triple backticks.
345    let start_idx = match s.find("```") {
346        Some(idx) => idx,
347        None => return s.to_string(),
348    };
349
350    // Find the end of the opening fence line (to skip optional language tag).
351    let after_fence = start_idx + 3;
352    let rest = &s[after_fence..];
353    let line_end_rel = match rest.find('\n') {
354        Some(rel) => rel,
355        None => return s.to_string(),
356    };
357    let content_start = after_fence + line_end_rel + 1;
358
359    // Search for the closing fence from content_start.
360    let rest_after = &s[content_start..];
361    let end_rel = match rest_after.find("```") {
362        Some(rel) => rel,
363        None => return s.to_string(),
364    };
365    let content_end = content_start + end_rel;
366
367    let mut block = s[content_start..content_end].to_string();
368    // Trim leading and trailing whitespace/newlines.
369    if block.starts_with('\n') {
370        block = block[1..].to_string();
371    }
372    block.trim_end().to_string()
373}
374
375impl Default for Handler {
376    fn default() -> Self {
377        Self::new()
378    }
379}