1use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::PathBuf;
12use tracing;
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
16#[serde(rename_all = "lowercase")]
17pub enum LlmProvider {
18 #[default]
20 Nvidia,
21 Ollama,
23 OpenAI,
25 Mlx,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(default)]
32pub struct PawanConfig {
33 pub provider: LlmProvider,
35
36 pub model: String,
38
39 pub base_url: Option<String>,
42
43 pub dry_run: bool,
45
46 pub auto_backup: bool,
48
49 pub require_git_clean: bool,
51
52 pub bash_timeout_secs: u64,
54
55 pub max_file_size_kb: usize,
57
58 pub max_tool_iterations: usize,
60 pub max_context_tokens: usize,
62
63 pub system_prompt: Option<String>,
65
66 pub temperature: f32,
68
69 pub top_p: f32,
71
72 pub max_tokens: usize,
74
75 pub thinking_budget: usize,
79
80 pub max_retries: usize,
82
83 pub fallback_models: Vec<String>,
85 pub max_result_chars: usize,
87
88 pub reasoning_mode: bool,
90
91 pub healing: HealingConfig,
93
94 pub targets: HashMap<String, TargetConfig>,
96
97 pub tui: TuiConfig,
99
100 #[serde(default)]
102 pub mcp: HashMap<String, McpServerEntry>,
103
104 #[serde(default)]
106 pub permissions: HashMap<String, ToolPermission>,
107
108 pub cloud: Option<CloudConfig>,
111
112 #[serde(default)]
115 pub models: ModelRouting,
116
117 #[serde(default)]
119 pub eruka: crate::eruka_bridge::ErukaConfig,
120}
121
122#[derive(Debug, Clone, Default, Serialize, Deserialize)]
132pub struct ModelRouting {
133 pub code: Option<String>,
135 pub orchestrate: Option<String>,
137 pub execute: Option<String>,
139}
140
141impl ModelRouting {
142 pub fn route(&self, query: &str) -> Option<&str> {
145 let q = query.to_lowercase();
146
147 if self.code.is_some() {
149 let code_signals = ["implement", "write", "create", "refactor", "fix", "add test",
150 "add function", "struct", "enum", "trait", "algorithm", "data structure"];
151 if code_signals.iter().any(|s| q.contains(s)) {
152 return self.code.as_deref();
153 }
154 }
155
156 if self.orchestrate.is_some() {
158 let orch_signals = ["search", "find", "analyze", "review", "explain", "compare",
159 "list", "check", "verify", "diagnose", "audit"];
160 if orch_signals.iter().any(|s| q.contains(s)) {
161 return self.orchestrate.as_deref();
162 }
163 }
164
165 if self.execute.is_some() {
167 let exec_signals = ["run", "execute", "bash", "cargo", "test", "build",
168 "deploy", "install", "commit"];
169 if exec_signals.iter().any(|s| q.contains(s)) {
170 return self.execute.as_deref();
171 }
172 }
173
174 None
175 }
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct CloudConfig {
195 pub provider: LlmProvider,
197 pub model: String,
199 #[serde(default)]
201 pub fallback_models: Vec<String>,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
206#[serde(rename_all = "lowercase")]
207pub enum ToolPermission {
208 Allow,
210 Deny,
212}
213
214impl Default for PawanConfig {
215 fn default() -> Self {
216 let mut targets = HashMap::new();
217 targets.insert(
218 "ares".to_string(),
219 TargetConfig {
220 path: PathBuf::from("../.."),
221 description: "A.R.E.S server codebase".to_string(),
222 },
223 );
224 targets.insert(
225 "self".to_string(),
226 TargetConfig {
227 path: PathBuf::from("."),
228 description: "Pawan's own codebase".to_string(),
229 },
230 );
231
232 Self {
233 provider: LlmProvider::Nvidia,
234 model: crate::DEFAULT_MODEL.to_string(),
235 base_url: None,
236 dry_run: false,
237 auto_backup: true,
238 require_git_clean: false,
239 bash_timeout_secs: crate::DEFAULT_BASH_TIMEOUT,
240 max_file_size_kb: 1024,
241 max_tool_iterations: crate::MAX_TOOL_ITERATIONS,
242 max_context_tokens: 100000,
243 system_prompt: None,
244 temperature: 1.0,
245 top_p: 0.95,
246 max_tokens: 8192,
247 thinking_budget: 0, reasoning_mode: true,
249 max_retries: 3,
250 fallback_models: Vec::new(),
251 max_result_chars: 8000,
252 healing: HealingConfig::default(),
253 targets,
254 tui: TuiConfig::default(),
255 mcp: HashMap::new(),
256 permissions: HashMap::new(),
257 cloud: None,
258 models: ModelRouting::default(),
259 eruka: crate::eruka_bridge::ErukaConfig::default(),
260 }
261 }
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
266#[serde(default)]
267pub struct HealingConfig {
268 pub auto_commit: bool,
270
271 pub fix_errors: bool,
273
274 pub fix_warnings: bool,
276
277 pub fix_tests: bool,
279
280 pub generate_docs: bool,
282
283 pub max_attempts: usize,
285}
286
287impl Default for HealingConfig {
288 fn default() -> Self {
289 Self {
290 auto_commit: false,
291 fix_errors: true,
292 fix_warnings: true,
293 fix_tests: true,
294 generate_docs: false,
295 max_attempts: 3,
296 }
297 }
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct TargetConfig {
307 pub path: PathBuf,
309
310 pub description: String,
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
316#[serde(default)]
317pub struct TuiConfig {
318 pub syntax_highlighting: bool,
320
321 pub theme: String,
323
324 pub line_numbers: bool,
326
327 pub mouse_support: bool,
329
330 pub scroll_speed: usize,
332
333 pub max_history: usize,
335}
336
337impl Default for TuiConfig {
338 fn default() -> Self {
339 Self {
340 syntax_highlighting: true,
341 theme: "base16-ocean.dark".to_string(),
342 line_numbers: true,
343 mouse_support: true,
344 scroll_speed: 3,
345 max_history: 1000,
346 }
347 }
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct McpServerEntry {
358 pub command: String,
360 #[serde(default)]
362 pub args: Vec<String>,
363 #[serde(default)]
365 pub env: HashMap<String, String>,
366 #[serde(default = "default_true")]
368 pub enabled: bool,
369}
370
371fn default_true() -> bool {
372 true
373}
374
375impl PawanConfig {
376 pub fn load(path: Option<&PathBuf>) -> crate::Result<Self> {
378 let config_path = path.cloned().or_else(|| {
379 let pawan_toml = PathBuf::from("pawan.toml");
381 if pawan_toml.exists() {
382 return Some(pawan_toml);
383 }
384
385 let ares_toml = PathBuf::from("ares.toml");
387 if ares_toml.exists() {
388 return Some(ares_toml);
389 }
390
391 if let Some(home) = dirs::home_dir() {
393 let global = home.join(".config/pawan/pawan.toml");
394 if global.exists() {
395 return Some(global);
396 }
397 }
398
399 None
400 });
401
402 match config_path {
403 Some(path) => {
404 let content = std::fs::read_to_string(&path).map_err(|e| {
405 crate::PawanError::Config(format!("Failed to read {}: {}", path.display(), e))
406 })?;
407
408 if path.file_name().map(|n| n == "ares.toml").unwrap_or(false) {
410 let value: toml::Value = toml::from_str(&content).map_err(|e| {
412 crate::PawanError::Config(format!(
413 "Failed to parse {}: {}",
414 path.display(),
415 e
416 ))
417 })?;
418
419 if let Some(pawan_section) = value.get("pawan") {
420 let config: PawanConfig =
421 pawan_section.clone().try_into().map_err(|e| {
422 crate::PawanError::Config(format!(
423 "Failed to parse [pawan] section: {}",
424 e
425 ))
426 })?;
427 return Ok(config);
428 }
429
430 Ok(Self::default())
432 } else {
433 toml::from_str(&content).map_err(|e| {
435 crate::PawanError::Config(format!(
436 "Failed to parse {}: {}",
437 path.display(),
438 e
439 ))
440 })
441 }
442 }
443 None => Ok(Self::default()),
444 }
445 }
446
447 pub fn apply_env_overrides(&mut self) {
449 if let Ok(model) = std::env::var("PAWAN_MODEL") {
450 self.model = model;
451 }
452 if let Ok(provider) = std::env::var("PAWAN_PROVIDER") {
453 match provider.to_lowercase().as_str() {
454 "nvidia" | "nim" => self.provider = LlmProvider::Nvidia,
455 "ollama" => self.provider = LlmProvider::Ollama,
456 "openai" => self.provider = LlmProvider::OpenAI,
457 "mlx" | "mlx-lm" => self.provider = LlmProvider::Mlx,
458 _ => tracing::warn!(provider = provider.as_str(), "Unknown PAWAN_PROVIDER, ignoring"),
459 }
460 }
461 if let Ok(temp) = std::env::var("PAWAN_TEMPERATURE") {
462 if let Ok(t) = temp.parse::<f32>() {
463 self.temperature = t;
464 }
465 }
466 if let Ok(tokens) = std::env::var("PAWAN_MAX_TOKENS") {
467 if let Ok(t) = tokens.parse::<usize>() {
468 self.max_tokens = t;
469 }
470 }
471 if let Ok(iters) = std::env::var("PAWAN_MAX_ITERATIONS") {
472 if let Ok(i) = iters.parse::<usize>() {
473 self.max_tool_iterations = i;
474 }
475 }
476 if let Ok(ctx) = std::env::var("PAWAN_MAX_CONTEXT_TOKENS") {
477 if let Ok(c) = ctx.parse::<usize>() {
478 self.max_context_tokens = c;
479 }
480 }
481 if let Ok(models) = std::env::var("PAWAN_FALLBACK_MODELS") {
482 self.fallback_models = models.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
483 }
484 if let Ok(chars) = std::env::var("PAWAN_MAX_RESULT_CHARS") {
485 if let Ok(c) = chars.parse::<usize>() {
486 self.max_result_chars = c;
487 }
488 }
489 }
490
491 pub fn get_target(&self, name: &str) -> Option<&TargetConfig> {
493 self.targets.get(name)
494 }
495
496 pub fn get_system_prompt(&self) -> String {
498 let base = self
499 .system_prompt
500 .clone()
501 .unwrap_or_else(|| DEFAULT_SYSTEM_PROMPT.to_string());
502
503 let context = Self::load_context_file();
505 if let Some(ctx) = context {
506 format!("{}\n\n## Project Context (from PAWAN.md)\n\n{}", base, ctx)
507 } else {
508 base
509 }
510 }
511
512 fn load_context_file() -> Option<String> {
514 for path in &["PAWAN.md", ".pawan/context.md"] {
516 let p = PathBuf::from(path);
517 if p.exists() {
518 if let Ok(content) = std::fs::read_to_string(&p) {
519 if !content.trim().is_empty() {
520 return Some(content);
521 }
522 }
523 }
524 }
525 None
526 }
527
528 pub fn use_thinking_mode(&self) -> bool {
531 self.reasoning_mode && self.model.contains("deepseek")
532 }
533}
534
535pub const DEFAULT_SYSTEM_PROMPT: &str = r#"You are Pawan, an expert coding assistant.
537
538CRITICAL — Efficiency rules (you have limited tool iterations):
539- Act immediately. Do NOT explore, plan, or check existence before writing.
540- write_file creates parents automatically. No need to mkdir first.
541- Write code FIRST, then verify. cargo check runs automatically after .rs writes.
542- Use relative paths from workspace root.
543- If a tool is missing, it will be auto-installed. Don't worry about dependencies.
544
545Tool priorities (use the best tool for the job):
546- Web search: use mcp_daedra_web_search for ANY web/internet query. It searches Wikipedia,
547 StackOverflow, GitHub, and more. NEVER use bash+curl for web search — use this tool.
548- Code edits: prefer ast_grep rewrite for structural changes (rename, refactor, pattern replace).
549 Only use edit_file/edit_file_lines for non-code files or when ast_grep can't express the change.
550- Code search: prefer ast_grep search for structural queries (find all functions, find unwrap() calls).
551 Use grep_search for text/regex patterns. Use glob_search for file discovery.
552- Project overview: use tree with disk_usage="line" for lines-of-code per directory.
553- Tool/runtime management: use mise to install, manage, or run any tool or language.
554
555Available tools:
556- File: read_file, write_file, edit_file, edit_file_lines, insert_after, append_file, list_directory
557- Code: ast_grep (AST search + rewrite)
558- Search: glob_search, grep_search, ripgrep, fd
559- Shell: bash, sd (find-replace), tree (erdtree), mise (tool/task/env manager), zoxide
560- Git: git_status, git_diff, git_add, git_commit, git_log, git_blame, git_branch, git_checkout, git_stash
561- Agent: spawn_agent, spawn_agents
562
563Rules:
5641. Make minimal, focused changes. Follow existing code style.
5652. After .rs writes, cargo check auto-runs — fix errors immediately if it fails.
5663. Run tests when the task calls for it (cargo test -p <crate>).
5674. One fix at a time. If it doesn't work, try a different approach.
568
569Be concise. Act first, explain briefly after.
570
571Git commits: always use author `bkataru <baalateja.k@gmail.com>` via -c flags."#;
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576
577 #[test]
578 fn test_provider_mlx_parsing() {
579 let toml = r#"
581provider = "mlx"
582model = "mlx-community/Qwen3.5-9B-4bit"
583"#;
584 let config: PawanConfig = toml::from_str(toml).expect("should parse without error");
585 assert_eq!(config.provider, LlmProvider::Mlx);
586 assert_eq!(config.model, "mlx-community/Qwen3.5-9B-4bit");
587 }
588
589 #[test]
590 fn test_provider_mlx_lm_alias() {
591 let mut config = PawanConfig::default();
593 std::env::set_var("PAWAN_PROVIDER", "mlx-lm");
594 config.apply_env_overrides();
595 std::env::remove_var("PAWAN_PROVIDER");
596 assert_eq!(config.provider, LlmProvider::Mlx);
597 }
598
599 #[test]
600 fn test_mlx_base_url_override() {
601 let toml = r#"
603provider = "mlx"
604model = "test-model"
605base_url = "http://192.168.1.100:8080/v1"
606"#;
607 let config: PawanConfig = toml::from_str(toml).expect("should parse without error");
608 assert_eq!(config.provider, LlmProvider::Mlx);
609 assert_eq!(
610 config.base_url.as_deref(),
611 Some("http://192.168.1.100:8080/v1")
612 );
613 }
614}