apple_code_assistant/cli/
handler.rs1use 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 let mut conversation_manager = if args.extend_conversation {
90 let mut m = ConversationManager::new();
91 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 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 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 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 let mut context_parts: Vec<String> = Vec::new();
174 if let Some(ctx) = args.context.clone() {
175 context_parts.push(ctx);
176 }
177
178 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 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 print!("{}", code);
257 } else {
258 if args.repeat_input && !std::io::stdin().is_terminal() {
259 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 let start_idx = match s.find("```") {
346 Some(idx) => idx,
347 None => return s.to_string(),
348 };
349
350 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 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 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}