1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::time::{Duration, SystemTime, UNIX_EPOCH};
4
5use tokio::fs;
6use tokio::io::AsyncReadExt;
7use tokio::process::Command;
8use tokio::time::timeout;
9use tracing::info;
10
11use crate::error::Result;
12use crate::path_utils::{is_forbidden_command, is_safe_command, validate_path};
13use crate::types::{
14 CodeConfig, CommandHistoryEntry, CommandResult, FileOperation, FileOperationType,
15};
16
17pub struct CoderService {
18 config: CodeConfig,
19 cwd_by_conversation: HashMap<String, PathBuf>,
20 history_by_conversation: HashMap<String, Vec<CommandHistoryEntry>>,
21 max_history_per_conversation: usize,
22}
23
24impl CoderService {
25 pub fn new(config: CodeConfig) -> Self {
26 info!("Coder service initialized");
27 Self {
28 cwd_by_conversation: HashMap::new(),
29 history_by_conversation: HashMap::new(),
30 config,
31 max_history_per_conversation: 100,
32 }
33 }
34
35 pub fn allowed_directory(&self) -> &Path {
36 &self.config.allowed_directory
37 }
38
39 pub fn current_directory(&self, conversation_id: &str) -> PathBuf {
40 self.cwd_by_conversation
41 .get(conversation_id)
42 .cloned()
43 .unwrap_or_else(|| self.config.allowed_directory.clone())
44 }
45
46 pub fn get_command_history(
47 &self,
48 conversation_id: &str,
49 limit: Option<usize>,
50 ) -> Vec<CommandHistoryEntry> {
51 let all = self
52 .history_by_conversation
53 .get(conversation_id)
54 .cloned()
55 .unwrap_or_default();
56 match limit {
57 None => all,
58 Some(l) => {
59 if l == 0 {
60 vec![]
61 } else if all.len() <= l {
62 all
63 } else {
64 all[all.len() - l..].to_vec()
65 }
66 }
67 }
68 }
69
70 fn now_ms() -> u64 {
71 SystemTime::now()
72 .duration_since(UNIX_EPOCH)
73 .unwrap_or_else(|_| Duration::from_millis(0))
74 .as_millis() as u64
75 }
76
77 fn ensure_enabled(&self) -> Option<String> {
78 if self.config.enabled {
79 None
80 } else {
81 Some("Coder plugin is disabled. Set CODER_ENABLED=true to enable.".to_string())
82 }
83 }
84
85 fn add_history(
86 &mut self,
87 conversation_id: &str,
88 command: &str,
89 result: &CommandResult,
90 file_ops: Option<Vec<FileOperation>>,
91 ) {
92 let list = self
93 .history_by_conversation
94 .entry(conversation_id.to_string())
95 .or_default();
96
97 list.push(CommandHistoryEntry {
98 timestamp_ms: Self::now_ms(),
99 working_directory: result.executed_in.clone(),
100 command: command.to_string(),
101 stdout: result.stdout.clone(),
102 stderr: result.stderr.clone(),
103 exit_code: result.exit_code,
104 file_operations: file_ops,
105 });
106
107 if list.len() > self.max_history_per_conversation {
108 let start = list.len() - self.max_history_per_conversation;
109 *list = list[start..].to_vec();
110 }
111 }
112
113 fn resolve_within(&self, conversation_id: &str, target: &str) -> Option<PathBuf> {
114 let cwd = self.current_directory(conversation_id);
115 validate_path(target, &self.config.allowed_directory, &cwd)
116 }
117
118 pub async fn change_directory(&mut self, conversation_id: &str, target: &str) -> CommandResult {
119 if let Some(msg) = self.ensure_enabled() {
120 return CommandResult {
121 success: false,
122 stdout: "".to_string(),
123 stderr: msg,
124 exit_code: Some(1),
125 error: Some("Coder disabled".to_string()),
126 executed_in: self
127 .current_directory(conversation_id)
128 .display()
129 .to_string(),
130 };
131 }
132
133 let resolved = match self.resolve_within(conversation_id, target) {
134 Some(p) => p,
135 None => {
136 return CommandResult {
137 success: false,
138 stdout: "".to_string(),
139 stderr: "Cannot navigate outside allowed directory".to_string(),
140 exit_code: Some(1),
141 error: Some("Permission denied".to_string()),
142 executed_in: self
143 .current_directory(conversation_id)
144 .display()
145 .to_string(),
146 }
147 }
148 };
149
150 let meta = fs::metadata(&resolved).await;
151 let is_dir = meta.map(|m| m.is_dir()).unwrap_or(false);
152 if !is_dir {
153 return CommandResult {
154 success: false,
155 stdout: "".to_string(),
156 stderr: "Not a directory".to_string(),
157 exit_code: Some(1),
158 error: Some("Not a directory".to_string()),
159 executed_in: self
160 .current_directory(conversation_id)
161 .display()
162 .to_string(),
163 };
164 }
165
166 self.cwd_by_conversation
167 .insert(conversation_id.to_string(), resolved.clone());
168
169 CommandResult {
170 success: true,
171 stdout: format!("Changed directory to: {}", resolved.display()),
172 stderr: "".to_string(),
173 exit_code: Some(0),
174 error: None,
175 executed_in: resolved.display().to_string(),
176 }
177 }
178
179 pub async fn read_file(
180 &self,
181 conversation_id: &str,
182 filepath: &str,
183 ) -> std::result::Result<String, String> {
184 if let Some(msg) = self.ensure_enabled() {
185 return Err(msg);
186 }
187 let resolved = self
188 .resolve_within(conversation_id, filepath)
189 .ok_or_else(|| "Cannot access path outside allowed directory".to_string())?;
190 let meta = fs::metadata(&resolved).await.map_err(|e| {
191 if e.kind() == std::io::ErrorKind::NotFound {
192 "File not found".to_string()
193 } else {
194 e.to_string()
195 }
196 })?;
197 if meta.is_dir() {
198 return Err("Path is a directory".to_string());
199 }
200 fs::read_to_string(&resolved)
201 .await
202 .map_err(|e| e.to_string())
203 }
204
205 pub async fn write_file(
206 &self,
207 conversation_id: &str,
208 filepath: &str,
209 content: &str,
210 ) -> std::result::Result<(), String> {
211 if let Some(msg) = self.ensure_enabled() {
212 return Err(msg);
213 }
214 if filepath.trim().is_empty() {
215 return Err("File path cannot be empty".to_string());
216 }
217 let resolved = self
218 .resolve_within(conversation_id, filepath)
219 .ok_or_else(|| "Cannot access path outside allowed directory".to_string())?;
220 if let Some(parent) = resolved.parent() {
221 if !parent.as_os_str().is_empty() {
222 fs::create_dir_all(parent)
223 .await
224 .map_err(|e| e.to_string())?;
225 }
226 }
227 fs::write(&resolved, content)
228 .await
229 .map_err(|e| e.to_string())
230 }
231
232 pub async fn edit_file(
233 &self,
234 conversation_id: &str,
235 filepath: &str,
236 old_str: &str,
237 new_str: &str,
238 ) -> std::result::Result<(), String> {
239 if let Some(msg) = self.ensure_enabled() {
240 return Err(msg);
241 }
242 let resolved = self
243 .resolve_within(conversation_id, filepath)
244 .ok_or_else(|| "Cannot access path outside allowed directory".to_string())?;
245 let content = fs::read_to_string(&resolved).await.map_err(|e| {
246 if e.kind() == std::io::ErrorKind::NotFound {
247 "File not found".to_string()
248 } else {
249 e.to_string()
250 }
251 })?;
252 if !content.contains(old_str) {
253 return Err("Could not find old_str in file".to_string());
254 }
255 let next = content.replacen(old_str, new_str, 1);
256 fs::write(&resolved, next).await.map_err(|e| e.to_string())
257 }
258
259 pub async fn list_files(
260 &self,
261 conversation_id: &str,
262 dirpath: &str,
263 ) -> std::result::Result<Vec<String>, String> {
264 if let Some(msg) = self.ensure_enabled() {
265 return Err(msg);
266 }
267 let resolved = self
268 .resolve_within(conversation_id, dirpath)
269 .ok_or_else(|| "Cannot access path outside allowed directory".to_string())?;
270 let mut entries = fs::read_dir(&resolved).await.map_err(|e| {
271 if e.kind() == std::io::ErrorKind::NotFound {
272 "Directory not found".to_string()
273 } else {
274 e.to_string()
275 }
276 })?;
277 let mut items: Vec<String> = vec![];
278 while let Some(e) = entries.next_entry().await.map_err(|e| e.to_string())? {
279 let name = e.file_name().to_string_lossy().to_string();
280 if name.starts_with('.') {
281 continue;
282 }
283 let meta = e.metadata().await.map_err(|e| e.to_string())?;
284 items.push(if meta.is_dir() {
285 format!("{}/", name)
286 } else {
287 name
288 });
289 }
290 items.sort();
291 Ok(items)
292 }
293
294 pub async fn search_files(
295 &self,
296 conversation_id: &str,
297 pattern: &str,
298 dirpath: &str,
299 max_matches: usize,
300 ) -> std::result::Result<Vec<(String, usize, String)>, String> {
301 if let Some(msg) = self.ensure_enabled() {
302 return Err(msg);
303 }
304 let needle = pattern.trim();
305 if needle.is_empty() {
306 return Err("Missing pattern".to_string());
307 }
308 let resolved = self
309 .resolve_within(conversation_id, dirpath)
310 .ok_or_else(|| "Cannot access path outside allowed directory".to_string())?;
311 let limit = if max_matches == 0 {
312 50
313 } else {
314 max_matches.min(500)
315 };
316 let mut matches: Vec<(String, usize, String)> = vec![];
317 self.search_dir(&resolved, needle.to_lowercase(), &mut matches, limit)
318 .await
319 .map_err(|e| e.to_string())?;
320 Ok(matches)
321 }
322
323 async fn search_dir(
324 &self,
325 dir: &Path,
326 needle_lower: String,
327 matches: &mut Vec<(String, usize, String)>,
328 limit: usize,
329 ) -> Result<()> {
330 if matches.len() >= limit {
331 return Ok(());
332 }
333
334 let mut stack: Vec<PathBuf> = vec![dir.to_path_buf()];
335
336 while let Some(current) = stack.pop() {
337 if matches.len() >= limit {
338 break;
339 }
340
341 let mut entries = match fs::read_dir(¤t).await {
342 Ok(e) => e,
343 Err(_) => continue,
344 };
345
346 while let Some(entry) = entries.next_entry().await? {
347 if matches.len() >= limit {
348 break;
349 }
350
351 let name = entry.file_name().to_string_lossy().to_string();
352 if name.starts_with('.') {
353 continue;
354 }
355
356 let p = entry.path();
357 let meta = entry.metadata().await?;
358
359 if meta.is_dir() {
360 if name == "node_modules"
361 || name == "dist"
362 || name == "build"
363 || name == "coverage"
364 || name == ".git"
365 {
366 continue;
367 }
368 stack.push(p);
369 continue;
370 }
371
372 if !meta.is_file() {
373 continue;
374 }
375
376 let mut f = fs::File::open(&p).await?;
377 let mut content = String::new();
378 let _ = f.read_to_string(&mut content).await;
379 for (i, line) in content.lines().enumerate() {
380 if matches.len() >= limit {
381 break;
382 }
383 if line.to_lowercase().contains(&needle_lower) {
384 let rel = p
385 .strip_prefix(&self.config.allowed_directory)
386 .unwrap_or(&p)
387 .display()
388 .to_string();
389 matches.push((rel, i + 1, line.trim().chars().take(240).collect()));
390 }
391 }
392 }
393 }
394
395 Ok(())
396 }
397
398 pub async fn execute_shell(
399 &mut self,
400 conversation_id: &str,
401 command: &str,
402 ) -> Result<CommandResult> {
403 if let Some(msg) = self.ensure_enabled() {
404 return Ok(CommandResult {
405 success: false,
406 stdout: "".to_string(),
407 stderr: msg,
408 exit_code: Some(1),
409 error: Some("Coder disabled".to_string()),
410 executed_in: self
411 .current_directory(conversation_id)
412 .display()
413 .to_string(),
414 });
415 }
416
417 let trimmed = command.trim();
418 if trimmed.is_empty() {
419 return Ok(CommandResult {
420 success: false,
421 stdout: "".to_string(),
422 stderr: "Invalid command".to_string(),
423 exit_code: Some(1),
424 error: Some("Empty command".to_string()),
425 executed_in: self
426 .current_directory(conversation_id)
427 .display()
428 .to_string(),
429 });
430 }
431
432 if !is_safe_command(trimmed) {
433 return Ok(CommandResult {
434 success: false,
435 stdout: "".to_string(),
436 stderr: "Command contains forbidden patterns".to_string(),
437 exit_code: Some(1),
438 error: Some("Security policy violation".to_string()),
439 executed_in: self
440 .current_directory(conversation_id)
441 .display()
442 .to_string(),
443 });
444 }
445
446 if is_forbidden_command(trimmed, &self.config.forbidden_commands) {
447 return Ok(CommandResult {
448 success: false,
449 stdout: "".to_string(),
450 stderr: "Command is forbidden by security policy".to_string(),
451 exit_code: Some(1),
452 error: Some("Forbidden command".to_string()),
453 executed_in: self
454 .current_directory(conversation_id)
455 .display()
456 .to_string(),
457 });
458 }
459
460 let cwd = self.current_directory(conversation_id);
461 let cwd_str = cwd.display().to_string();
462
463 let use_shell = trimmed.contains('>') || trimmed.contains('<') || trimmed.contains('|');
464 let mut cmd = if use_shell {
465 let mut c = Command::new("sh");
466 c.args(["-c", trimmed]);
467 c
468 } else {
469 let parts: Vec<&str> = trimmed.split_whitespace().collect();
470 let mut c = Command::new(parts[0]);
471 if parts.len() > 1 {
472 c.args(&parts[1..]);
473 }
474 c
475 };
476
477 cmd.current_dir(&cwd)
478 .stdout(std::process::Stdio::piped())
479 .stderr(std::process::Stdio::piped());
480
481 let timeout_duration = Duration::from_millis(self.config.timeout_ms);
482 let spawn = cmd
483 .spawn()
484 .map_err(|e| crate::error::CodeError::Process(e.to_string()))?;
485
486 let output = timeout(timeout_duration, spawn.wait_with_output()).await;
487
488 let result = match output {
489 Ok(Ok(out)) => {
490 let stdout = String::from_utf8_lossy(&out.stdout).to_string();
491 let stderr = String::from_utf8_lossy(&out.stderr).to_string();
492 CommandResult {
493 success: out.status.success(),
494 stdout,
495 stderr,
496 exit_code: out.status.code(),
497 error: None,
498 executed_in: cwd_str.clone(),
499 }
500 }
501 Ok(Err(e)) => CommandResult {
502 success: false,
503 stdout: "".to_string(),
504 stderr: e.to_string(),
505 exit_code: Some(1),
506 error: Some("Command failed".to_string()),
507 executed_in: cwd_str.clone(),
508 },
509 Err(_) => CommandResult {
510 success: false,
511 stdout: "".to_string(),
512 stderr: "Command timed out".to_string(),
513 exit_code: None,
514 error: Some("Command execution timeout".to_string()),
515 executed_in: cwd_str.clone(),
516 },
517 };
518
519 self.add_history(conversation_id, trimmed, &result, None);
520 Ok(result)
521 }
522
523 pub async fn git(&mut self, conversation_id: &str, args: &str) -> Result<CommandResult> {
524 self.execute_shell(conversation_id, &format!("git {}", args))
525 .await
526 }
527
528 pub fn note_file_op(
529 &mut self,
530 conversation_id: &str,
531 op_type: FileOperationType,
532 target: &str,
533 ) {
534 let cwd = self
535 .current_directory(conversation_id)
536 .display()
537 .to_string();
538 let entry = CommandResult {
539 success: true,
540 stdout: "".to_string(),
541 stderr: "".to_string(),
542 exit_code: Some(0),
543 error: None,
544 executed_in: cwd,
545 };
546 self.add_history(
547 conversation_id,
548 "<file_op>",
549 &entry,
550 Some(vec![FileOperation {
551 r#type: op_type,
552 target: target.to_string(),
553 }]),
554 );
555 }
556}