1use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::PathBuf;
12use tracing;
13
14const fn default_config_version() -> u32 {
16 1
17}
18
19pub(super) const fn default_tool_idle_timeout() -> u64 {
21 300
22}
23
24
25pub mod migration;
26pub use migration::{migrate_to_latest, save_config, MigrationResult};
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
30#[serde(rename_all = "lowercase")]
31pub enum LlmProvider {
32 #[default]
34 Nvidia,
35 Ollama,
37 OpenAI,
39 Mlx,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(default)]
46pub struct PawanConfig {
47 #[serde(default = "default_config_version")]
49 pub config_version: u32,
50
51 pub provider: LlmProvider,
53
54 pub model: String,
56
57 pub base_url: Option<String>,
60
61 pub dry_run: bool,
63
64 pub auto_backup: bool,
66
67 pub require_git_clean: bool,
69
70 pub bash_timeout_secs: u64,
72
73 #[serde(default = "default_tool_idle_timeout")]
76 pub tool_call_idle_timeout_secs: u64,
77
78 pub max_file_size_kb: usize,
80
81 pub max_tool_iterations: usize,
83 pub max_context_tokens: usize,
85
86 pub system_prompt: Option<String>,
88
89 pub temperature: f32,
91
92 pub top_p: f32,
94
95 pub max_tokens: usize,
97
98 pub thinking_budget: usize,
102
103 pub max_retries: usize,
105
106 pub fallback_models: Vec<String>,
108 pub max_result_chars: usize,
110
111 pub reasoning_mode: bool,
113
114 pub healing: HealingConfig,
116
117 pub targets: HashMap<String, TargetConfig>,
119
120 pub tui: TuiConfig,
122
123 #[serde(default)]
125 pub mcp: HashMap<String, McpServerEntry>,
126
127 #[serde(default)]
129 pub permissions: HashMap<String, ToolPermission>,
130
131 pub cloud: Option<CloudConfig>,
134
135 #[serde(default)]
138 pub models: ModelRouting,
139
140 #[serde(default)]
142 pub eruka: crate::eruka_bridge::ErukaConfig,
143
144 #[serde(default)]
150 pub use_ares_backend: bool,
151 #[serde(default)]
156 pub use_coordinator: bool,
157
158 #[serde(default)]
171 pub skills_repo: Option<PathBuf>,
172
173 #[serde(default)]
180 pub local_first: bool,
181
182 #[serde(default)]
186 pub local_endpoint: Option<String>,
187}
188
189#[derive(Debug, Clone, Default, Serialize, Deserialize)]
199pub struct ModelRouting {
200 pub code: Option<String>,
202 pub orchestrate: Option<String>,
204 pub execute: Option<String>,
206}
207
208impl ModelRouting {
209 pub fn route(&self, query: &str) -> Option<&str> {
212 let q = query.to_lowercase();
213
214 if self.code.is_some() {
216 let code_signals = ["implement", "write", "create", "refactor", "fix", "add test",
217 "add function", "struct", "enum", "trait", "algorithm", "data structure"];
218 if code_signals.iter().any(|s| q.contains(s)) {
219 return self.code.as_deref();
220 }
221 }
222
223 if self.orchestrate.is_some() {
225 let orch_signals = ["search", "find", "analyze", "review", "explain", "compare",
226 "list", "check", "verify", "diagnose", "audit"];
227 if orch_signals.iter().any(|s| q.contains(s)) {
228 return self.orchestrate.as_deref();
229 }
230 }
231
232 if self.execute.is_some() {
234 let exec_signals = ["run", "execute", "bash", "cargo", "test", "build",
235 "deploy", "install", "commit"];
236 if exec_signals.iter().any(|s| q.contains(s)) {
237 return self.execute.as_deref();
238 }
239 }
240
241 None
242 }
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct CloudConfig {
262 pub provider: LlmProvider,
264 pub model: String,
266 #[serde(default)]
268 pub fallback_models: Vec<String>,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
273#[serde(rename_all = "lowercase")]
274pub enum ToolPermission {
275 Allow,
277 Deny,
279 Prompt,
281}
282
283impl ToolPermission {
284 pub fn resolve(name: &str, permissions: &HashMap<String, ToolPermission>) -> Self {
289 if let Some(p) = permissions.get(name) {
290 return p.clone();
291 }
292 match name {
294 "bash" | "git_commit" | "write_file" | "edit_file_lines"
295 | "insert_after" | "append_file" => ToolPermission::Allow, _ => ToolPermission::Allow,
297 }
298 }
299}
300
301impl Default for PawanConfig {
302 fn default() -> Self {
303 let mut targets = HashMap::new();
304 targets.insert(
305 "self".to_string(),
306 TargetConfig {
307 path: PathBuf::from("."),
308 description: "Current project codebase".to_string(),
309 },
310 );
311
312 Self {
313 provider: LlmProvider::Nvidia,
314 config_version: default_config_version(),
315 model: crate::DEFAULT_MODEL.to_string(),
316 base_url: None,
317 dry_run: false,
318 auto_backup: true,
319 require_git_clean: false,
320 bash_timeout_secs: crate::DEFAULT_BASH_TIMEOUT,
321 tool_call_idle_timeout_secs: default_tool_idle_timeout(),
322 max_file_size_kb: 1024,
323 max_tool_iterations: crate::MAX_TOOL_ITERATIONS,
324 max_context_tokens: 100000,
325 system_prompt: None,
326 temperature: 1.0,
327 top_p: 0.95,
328 max_tokens: 8192,
329 thinking_budget: 0, reasoning_mode: true,
331 max_retries: 3,
332 fallback_models: Vec::new(),
333 max_result_chars: 8000,
334 healing: HealingConfig::default(),
335 targets,
336 tui: TuiConfig::default(),
337 mcp: HashMap::new(),
338 permissions: HashMap::new(),
339 cloud: None,
340 models: ModelRouting::default(),
341 eruka: crate::eruka_bridge::ErukaConfig::default(),
342 use_ares_backend: false,
343 use_coordinator: false,
344 skills_repo: None,
345 local_first: false,
346 local_endpoint: None,
347 }
348 }
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize)]
353#[serde(default)]
354pub struct HealingConfig {
355 pub auto_commit: bool,
357
358 pub fix_errors: bool,
360
361 pub fix_warnings: bool,
363
364 pub fix_tests: bool,
366
367 pub generate_docs: bool,
369
370 #[serde(default)]
374 pub fix_security: bool,
375
376 pub max_attempts: usize,
378
379 #[serde(default)]
386 pub verify_cmd: Option<String>,
387}
388
389impl Default for HealingConfig {
390 fn default() -> Self {
391 Self {
392 auto_commit: false,
393 fix_errors: true,
394 fix_warnings: true,
395 fix_tests: true,
396 generate_docs: false,
397 fix_security: false,
398 max_attempts: 3,
399 verify_cmd: None,
400 }
401 }
402}
403
404#[derive(Debug, Clone, Serialize, Deserialize)]
406pub struct TargetConfig {
411 pub path: PathBuf,
413
414 pub description: String,
416}
417
418#[derive(Debug, Clone, Serialize, Deserialize)]
420#[serde(default)]
421pub struct TuiConfig {
422 pub syntax_highlighting: bool,
424
425 pub theme: String,
427
428 pub line_numbers: bool,
430
431 pub mouse_support: bool,
433
434 pub scroll_speed: usize,
436
437 pub max_history: usize,
439
440 pub auto_save_enabled: bool,
442 pub auto_save_interval_minutes: u32,
444 pub auto_save_dir: Option<std::path::PathBuf>,
446}
447
448impl Default for TuiConfig {
449 fn default() -> Self {
450 Self {
451 syntax_highlighting: true,
452 theme: "base16-ocean.dark".to_string(),
453 line_numbers: true,
454 mouse_support: true,
455 scroll_speed: 3,
456 max_history: 1000,
457 auto_save_enabled: true,
458 auto_save_interval_minutes: 5,
459 auto_save_dir: None,
460 }
461 }
462}
463
464#[derive(Debug, Clone, Serialize, Deserialize)]
466pub struct McpServerEntry {
472 pub command: String,
474 #[serde(default)]
476 pub args: Vec<String>,
477 #[serde(default)]
479 pub env: HashMap<String, String>,
480 #[serde(default = "default_true")]
482 pub enabled: bool,
483}
484
485fn default_true() -> bool {
486 true
487}
488
489impl PawanConfig {
490 pub fn load(path: Option<&PathBuf>) -> crate::Result<Self> {
492 let config_path = path.cloned().or_else(|| {
493 let pawan_toml = PathBuf::from("pawan.toml");
495 if pawan_toml.exists() {
496 return Some(pawan_toml);
497 }
498
499 let ares_toml = PathBuf::from("ares.toml");
501 if ares_toml.exists() {
502 return Some(ares_toml);
503 }
504
505 if let Some(home) = dirs::home_dir() {
507 let global = home.join(".config/pawan/pawan.toml");
508 if global.exists() {
509 return Some(global);
510 }
511 }
512
513 None
514 });
515
516 match config_path {
517 Some(path) => {
518 let content = std::fs::read_to_string(&path).map_err(|e| {
519 crate::PawanError::Config(format!("Failed to read {}: {}", path.display(), e))
520 })?;
521
522 if path.file_name().map(|n| n == "ares.toml").unwrap_or(false) {
524 let value: toml::Value = toml::from_str(&content).map_err(|e| {
526 crate::PawanError::Config(format!(
527 "Failed to parse {}: {}",
528 path.display(),
529 e
530 ))
531 })?;
532
533 if let Some(pawan_section) = value.get("pawan") {
534 let config: PawanConfig =
535 pawan_section.clone().try_into().map_err(|e| {
536 crate::PawanError::Config(format!(
537 "Failed to parse [pawan] section: {}",
538 e
539 ))
540 })?;
541 return Ok(config);
542 }
543
544 Ok(Self::default())
546 } else {
547 let mut config: PawanConfig = toml::from_str(&content).map_err(|e| {
549 crate::PawanError::Config(format!(
550 "Failed to parse {}: {}",
551 path.display(),
552 e
553 ))
554 })?;
555
556 let migration_result = migrate_to_latest(&mut config, Some(&path));
558 if migration_result.migrated {
559 tracing::info!(
560 from_version = migration_result.from_version,
561 to_version = migration_result.to_version,
562 backup = ?migration_result.backup_path,
563 "Config migrated"
564 );
565
566 if let Err(e) = save_config(&config, &path) {
568 tracing::warn!(error = %e, "Failed to save migrated config");
569 }
570 }
571
572 Ok(config)
573 }
574 }
575 None => Ok(Self::default()),
576 }
577 }
578
579 pub fn apply_env_overrides(&mut self) {
581 if let Ok(model) = std::env::var("PAWAN_MODEL") {
582 self.model = model;
583 }
584 if let Ok(provider) = std::env::var("PAWAN_PROVIDER") {
585 match provider.to_lowercase().as_str() {
586 "nvidia" | "nim" => self.provider = LlmProvider::Nvidia,
587 "ollama" => self.provider = LlmProvider::Ollama,
588 "openai" => self.provider = LlmProvider::OpenAI,
589 "mlx" | "mlx-lm" => self.provider = LlmProvider::Mlx,
590 _ => tracing::warn!(provider = provider.as_str(), "Unknown PAWAN_PROVIDER, ignoring"),
591 }
592 }
593 if let Ok(temp) = std::env::var("PAWAN_TEMPERATURE") {
594 if let Ok(t) = temp.parse::<f32>() {
595 self.temperature = t;
596 }
597 }
598 if let Ok(tokens) = std::env::var("PAWAN_MAX_TOKENS") {
599 if let Ok(t) = tokens.parse::<usize>() {
600 self.max_tokens = t;
601 }
602 }
603 if let Ok(iters) = std::env::var("PAWAN_MAX_ITERATIONS") {
604 if let Ok(i) = iters.parse::<usize>() {
605 self.max_tool_iterations = i;
606 }
607 }
608 if let Ok(ctx) = std::env::var("PAWAN_MAX_CONTEXT_TOKENS") {
609 if let Ok(c) = ctx.parse::<usize>() {
610 self.max_context_tokens = c;
611 }
612 }
613 if let Ok(models) = std::env::var("PAWAN_FALLBACK_MODELS") {
614 self.fallback_models = models.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
615 }
616 if let Ok(chars) = std::env::var("PAWAN_MAX_RESULT_CHARS") {
617 if let Ok(c) = chars.parse::<usize>() {
618 self.max_result_chars = c;
619 }
620 }
621 }
622
623 pub fn get_target(&self, name: &str) -> Option<&TargetConfig> {
625 self.targets.get(name)
626 }
627
628 pub fn get_system_prompt(&self) -> String {
631 let base = self
632 .system_prompt
633 .clone()
634 .unwrap_or_else(|| DEFAULT_SYSTEM_PROMPT.to_string());
635
636 let mut prompt = base;
637
638 if let Some((filename, ctx)) = Self::load_context_file() {
639 prompt = format!("{}\n\n## Project Context (from {})\n\n{}", prompt, filename, ctx);
640 }
641
642 if let Some(skill_ctx) = Self::load_skill_context() {
643 prompt = format!("{}\n\n## Active Skill (from SKILL.md)\n\n{}", prompt, skill_ctx);
644 }
645
646 prompt
647 }
648
649 fn load_context_file() -> Option<(String, String)> {
653 for path in &["PAWAN.md", "AGENTS.md", "CLAUDE.md", ".pawan/context.md"] {
654 let p = PathBuf::from(path);
655 if p.exists() {
656 if let Ok(content) = std::fs::read_to_string(&p) {
657 if !content.trim().is_empty() {
658 return Some((path.to_string(), content));
659 }
660 }
661 }
662 }
663 None
664 }
665
666 fn load_skill_context() -> Option<String> {
670 use thulp_skill_files::SkillFile;
671
672 let skill_path = std::path::Path::new("SKILL.md");
673 if !skill_path.exists() {
674 return None;
675 }
676
677 match SkillFile::parse(skill_path) {
678 Ok(skill) => {
679 let name = skill.effective_name();
680 let desc = skill.frontmatter.description.as_deref().unwrap_or("no description");
681 let tools_str = match &skill.frontmatter.allowed_tools {
682 Some(tools) => tools.join(", "),
683 None => "all".to_string(),
684 };
685 Some(format!(
686 "[Skill: {}] {}\nAllowed tools: {}\n---\n{}",
687 name, desc, tools_str, skill.content
688 ))
689 }
690 Err(e) => {
691 tracing::warn!("Failed to parse SKILL.md: {}", e);
692 None
693 }
694 }
695 }
696
697 pub fn resolve_skills_repo(&self) -> Option<PathBuf> {
704 if let Ok(env_path) = std::env::var("PAWAN_SKILLS_REPO") {
706 let p = PathBuf::from(env_path);
707 if p.is_dir() {
708 return Some(p);
709 }
710 tracing::warn!(path = %p.display(), "PAWAN_SKILLS_REPO set but directory does not exist");
711 }
712
713 if let Some(ref p) = self.skills_repo {
715 if p.is_dir() {
716 return Some(p.clone());
717 }
718 tracing::warn!(path = %p.display(), "config.skills_repo set but directory does not exist");
719 }
720
721 if let Some(home) = dirs::home_dir() {
723 let default = home.join(".config").join("pawan").join("skills");
724 if default.is_dir() {
725 return Some(default);
726 }
727 }
728
729 None
730 }
731
732 pub fn auto_discover_mcp_servers(&mut self) -> Vec<String> {
743 let mut discovered = Vec::new();
744
745 if !self.mcp.contains_key("eruka") && which::which("eruka-mcp").is_ok() {
747 self.mcp.insert(
748 "eruka".to_string(),
749 McpServerEntry {
750 command: "eruka-mcp".to_string(),
751 args: vec!["--transport".to_string(), "stdio".to_string()],
752 env: HashMap::new(),
753 enabled: true,
754 },
755 );
756 discovered.push("eruka".to_string());
757 tracing::info!("auto-discovered eruka-mcp");
758 }
759
760 if !self.mcp.contains_key("daedra") && which::which("daedra").is_ok() {
762 self.mcp.insert(
763 "daedra".to_string(),
764 McpServerEntry {
765 command: "daedra".to_string(),
766 args: vec![
767 "serve".to_string(),
768 "--transport".to_string(),
769 "stdio".to_string(),
770 "--quiet".to_string(),
771 ],
772 env: HashMap::new(),
773 enabled: true,
774 },
775 );
776 discovered.push("daedra".to_string());
777 tracing::info!("auto-discovered daedra");
778 }
779
780 if !self.mcp.contains_key("deagle") && which::which("deagle-mcp").is_ok() {
782 self.mcp.insert(
783 "deagle".to_string(),
784 McpServerEntry {
785 command: "deagle-mcp".to_string(),
786 args: vec!["--transport".to_string(), "stdio".to_string()],
787 env: HashMap::new(),
788 enabled: true,
789 },
790 );
791 discovered.push("deagle".to_string());
792 tracing::info!("auto-discovered deagle-mcp");
793 }
794
795 discovered
796 }
797
798 pub fn discover_skills_from_repo(&self) -> Vec<(String, String, PathBuf)> {
809 use thulp_skill_files::SkillFile;
810
811 let repo = match self.resolve_skills_repo() {
812 Some(r) => r,
813 None => return Vec::new(),
814 };
815
816 let mut results = Vec::new();
817 let walker = match std::fs::read_dir(&repo) {
818 Ok(w) => w,
819 Err(e) => {
820 tracing::warn!(path = %repo.display(), error = %e, "failed to read skills repo");
821 return Vec::new();
822 }
823 };
824
825 for entry in walker.flatten() {
826 let path = entry.path();
827 let skill_file = path.join("SKILL.md");
829 if !skill_file.is_file() {
830 continue;
831 }
832 match SkillFile::parse(&skill_file) {
833 Ok(skill) => {
834 let name = skill.effective_name();
835 let desc = skill
836 .frontmatter
837 .description
838 .clone()
839 .unwrap_or_else(|| "(no description)".to_string());
840 results.push((name, desc, skill_file));
841 }
842 Err(e) => {
843 tracing::debug!(path = %skill_file.display(), error = %e, "skip unparseable skill");
844 }
845 }
846 }
847
848 results.sort_by(|a, b| a.0.cmp(&b.0));
849 results
850 }
851
852 pub fn use_thinking_mode(&self) -> bool {
855 self.reasoning_mode
856 && (self.model.contains("deepseek")
857 || self.model.contains("gemma")
858 || self.model.contains("glm")
859 || self.model.contains("qwen")
860 || self.model.contains("mistral-small-4"))
861 }
862}
863
864pub const DEFAULT_SYSTEM_PROMPT: &str = r#"You are Pawan, an expert coding assistant.
866
867# Efficiency
868- Act immediately. Do NOT explore or plan before writing. Write code FIRST, then verify.
869- write_file creates parents automatically. No mkdir needed.
870- cargo check runs automatically after .rs writes — fix errors immediately.
871- Use relative paths from workspace root.
872- Missing tools are auto-installed via mise. Don't check dependencies.
873- You have limited tool iterations. Be direct. No preamble.
874
875# Tool Selection
876Use the BEST tool for the job — do NOT use bash for things dedicated tools handle:
877- File ops: read_file, write_file, edit_file, edit_file_lines, insert_after, append_file, list_directory
878- Code intelligence: ast_grep (AST search + rewrite via tree-sitter — prefer for structural changes)
879- Search: glob_search (files by pattern), grep_search (content by regex), ripgrep (native rg), fd (native find)
880- Shell: bash (commands), sd (find-replace in files), mise (tool/task/env manager), zoxide (smart cd)
881- Git: git_status, git_diff, git_add, git_commit, git_log, git_blame, git_branch, git_checkout, git_stash
882- Agent: spawn_agent (delegate subtask), spawn_agents (parallel sub-agents)
883- Web: mcp_daedra_web_search (ALWAYS use for web queries — never bash+curl)
884
885Prefer ast_grep over edit_file for code refactors. Prefer grep_search over bash grep.
886Prefer fd over bash find. Prefer sd over bash sed.
887
888# Parallel Execution
889Call multiple tools in a single response when they are independent.
890If tool B depends on tool A's result, call them sequentially.
891Never parallelize destructive operations (writes, deletes, commits).
892
893# Read Before Modifying
894Do NOT propose changes to code you haven't read. If asked to modify a file, read it first.
895Understand existing code, patterns, and style before suggesting changes.
896
897# Scope Discipline
898Make minimal, focused changes. Follow existing code style.
899- Don't add features, refactor, or "improve" code beyond what was asked.
900- Don't add docstrings, comments, or type annotations to code you didn't change.
901- A bug fix doesn't need surrounding code cleaned up.
902- Don't add error handling for scenarios that can't happen.
903
904# Executing Actions with Care
905Consider reversibility and blast radius before acting:
906- Freely take local, reversible actions (editing files, running tests).
907- For hard-to-reverse actions (force-push, rm -rf, dropping tables), ask first.
908- Match the scope of your actions to what was requested.
909- Investigate before deleting — unfamiliar files may be the user's in-progress work.
910- Don't use destructive shortcuts to bypass safety checks.
911
912# Git Safety
913- NEVER skip hooks (--no-verify) unless explicitly asked.
914- ALWAYS create NEW commits rather than amending (amend after hook failure destroys work).
915- NEVER force-push to main/master. Warn if requested.
916- Prefer staging specific files over `git add -A` (avoids committing secrets).
917- Only commit when explicitly asked. Don't be over-eager.
918- Commit messages: focus on WHY, not WHAT. Use HEREDOC for multi-line messages.
919- Use the git author from `git config user.name` / `git config user.email`.
920
921# Output Style
922Be concise. Lead with the answer, not the reasoning.
923Focus text output on: decisions needing input, status updates, errors/blockers.
924If you can say it in one sentence, don't use three.
925After .rs writes, cargo check auto-runs — fix errors immediately if it fails.
926Run tests when the task calls for it (cargo test -p <crate>).
927One fix at a time. If it doesn't work, try a different approach."#;
928
929#[cfg(test)]
930mod tests {
931 use super::*;
932
933 #[test]
934 fn test_provider_mlx_parsing() {
935 let toml = r#"
937provider = "mlx"
938model = "mlx-community/Qwen3.5-9B-4bit"
939"#;
940 let config: PawanConfig = toml::from_str(toml).expect("should parse without error");
941 assert_eq!(config.provider, LlmProvider::Mlx);
942 assert_eq!(config.model, "mlx-community/Qwen3.5-9B-4bit");
943 }
944
945 #[test]
946 fn test_provider_mlx_lm_alias() {
947 let mut config = PawanConfig::default();
949 std::env::set_var("PAWAN_PROVIDER", "mlx-lm");
950 config.apply_env_overrides();
951 std::env::remove_var("PAWAN_PROVIDER");
952 assert_eq!(config.provider, LlmProvider::Mlx);
953 }
954
955 #[test]
956 fn test_mlx_base_url_override() {
957 let toml = r#"
959provider = "mlx"
960model = "test-model"
961base_url = "http://192.168.1.100:8080/v1"
962"#;
963 let config: PawanConfig = toml::from_str(toml).expect("should parse without error");
964 assert_eq!(config.provider, LlmProvider::Mlx);
965 assert_eq!(
966 config.base_url.as_deref(),
967 Some("http://192.168.1.100:8080/v1")
968 );
969 }
970
971 #[test]
974 fn test_route_code_signals() {
975 let routing = ModelRouting {
976 code: Some("code-model".into()),
977 orchestrate: Some("orch-model".into()),
978 execute: Some("exec-model".into()),
979 };
980 assert_eq!(routing.route("implement a linked list"), Some("code-model"));
981 assert_eq!(routing.route("refactor the parser"), Some("code-model"));
982 assert_eq!(routing.route("add test for config"), Some("code-model"));
983 assert_eq!(routing.route("Write a new struct"), Some("code-model"));
984 }
985
986 #[test]
987 fn test_route_orchestration_signals() {
988 let routing = ModelRouting {
989 code: Some("code-model".into()),
990 orchestrate: Some("orch-model".into()),
991 execute: Some("exec-model".into()),
992 };
993 assert_eq!(routing.route("analyze the error logs"), Some("orch-model"));
994 assert_eq!(routing.route("review this PR"), Some("orch-model"));
995 assert_eq!(routing.route("explain how the agent works"), Some("orch-model"));
996 assert_eq!(routing.route("search for uses of foo"), Some("orch-model"));
997 }
998
999 #[test]
1000 fn test_route_execution_signals() {
1001 let routing = ModelRouting {
1002 code: Some("code-model".into()),
1003 orchestrate: Some("orch-model".into()),
1004 execute: Some("exec-model".into()),
1005 };
1006 assert_eq!(routing.route("run cargo test"), Some("exec-model"));
1007 assert_eq!(routing.route("execute the deploy script"), Some("exec-model"));
1008 assert_eq!(routing.route("build the project"), Some("exec-model"));
1009 assert_eq!(routing.route("commit these changes"), Some("exec-model"));
1010 }
1011
1012 #[test]
1013 fn test_route_no_match_returns_none() {
1014 let routing = ModelRouting {
1015 code: Some("code-model".into()),
1016 orchestrate: Some("orch-model".into()),
1017 execute: Some("exec-model".into()),
1018 };
1019 assert_eq!(routing.route("hello world"), None);
1020 }
1021
1022 #[test]
1023 fn test_route_empty_routing_returns_none() {
1024 let routing = ModelRouting::default();
1025 assert_eq!(routing.route("implement something"), None);
1026 assert_eq!(routing.route("search for bugs"), None);
1027 }
1028
1029 #[test]
1030 fn test_route_case_insensitive() {
1031 let routing = ModelRouting {
1032 code: Some("code-model".into()),
1033 orchestrate: None,
1034 execute: None,
1035 };
1036 assert_eq!(routing.route("IMPLEMENT a FUNCTION"), Some("code-model"));
1037 }
1038
1039 #[test]
1040 fn test_route_partial_routing() {
1041 let routing = ModelRouting {
1043 code: Some("code-model".into()),
1044 orchestrate: None,
1045 execute: None,
1046 };
1047 assert_eq!(routing.route("implement x"), Some("code-model"));
1048 assert_eq!(routing.route("search for y"), None);
1049 assert_eq!(routing.route("run tests"), None);
1050 }
1051
1052 #[test]
1055 fn test_env_override_model() {
1056 let mut config = PawanConfig::default();
1057 std::env::set_var("PAWAN_MODEL", "custom/model-123");
1058 config.apply_env_overrides();
1059 std::env::remove_var("PAWAN_MODEL");
1060 assert_eq!(config.model, "custom/model-123");
1061 }
1062
1063 #[test]
1064 fn test_env_override_temperature() {
1065 let mut config = PawanConfig::default();
1066 std::env::set_var("PAWAN_TEMPERATURE", "0.9");
1067 config.apply_env_overrides();
1068 std::env::remove_var("PAWAN_TEMPERATURE");
1069 assert!((config.temperature - 0.9).abs() < f32::EPSILON);
1070 }
1071
1072 #[test]
1073 fn test_env_override_invalid_temperature_ignored() {
1074 let mut config = PawanConfig::default();
1075 let original = config.temperature;
1076 std::env::set_var("PAWAN_TEMPERATURE", "not_a_number");
1077 config.apply_env_overrides();
1078 std::env::remove_var("PAWAN_TEMPERATURE");
1079 assert!((config.temperature - original).abs() < f32::EPSILON);
1080 }
1081
1082 #[test]
1083 fn test_env_override_max_tokens() {
1084 let mut config = PawanConfig::default();
1085 std::env::set_var("PAWAN_MAX_TOKENS", "16384");
1086 config.apply_env_overrides();
1087 std::env::remove_var("PAWAN_MAX_TOKENS");
1088 assert_eq!(config.max_tokens, 16384);
1089 }
1090
1091 #[test]
1092 fn test_env_override_fallback_models() {
1093 std::env::remove_var("PAWAN_FALLBACK_MODELS"); let mut config = PawanConfig::default();
1095 std::env::set_var("PAWAN_FALLBACK_MODELS", "model-a, model-b, model-c");
1096 config.apply_env_overrides();
1097 std::env::remove_var("PAWAN_FALLBACK_MODELS");
1098 assert_eq!(config.fallback_models, vec!["model-a", "model-b", "model-c"]);
1099 }
1100
1101 #[test]
1102 fn test_env_override_fallback_models_filters_empty() {
1103 std::env::remove_var("PAWAN_FALLBACK_MODELS"); let mut config = PawanConfig::default();
1105 std::env::set_var("PAWAN_FALLBACK_MODELS", "model-a,,, model-b,");
1106 config.apply_env_overrides();
1107 std::env::remove_var("PAWAN_FALLBACK_MODELS");
1108 assert_eq!(config.fallback_models, vec!["model-a", "model-b"]);
1109 }
1110
1111 #[test]
1112 fn test_env_override_provider_variants() {
1113 for (env_val, expected) in [
1114 ("nvidia", LlmProvider::Nvidia),
1115 ("nim", LlmProvider::Nvidia),
1116 ("ollama", LlmProvider::Ollama),
1117 ("openai", LlmProvider::OpenAI),
1118 ("mlx", LlmProvider::Mlx),
1119 ] {
1120 let mut config = PawanConfig::default();
1121 std::env::set_var("PAWAN_PROVIDER", env_val);
1122 config.apply_env_overrides();
1123 std::env::remove_var("PAWAN_PROVIDER");
1124 assert_eq!(config.provider, expected, "PAWAN_PROVIDER={} should map to {:?}", env_val, expected);
1125 }
1126 }
1127
1128 #[test]
1131 fn test_thinking_mode_supported_models() {
1132 for model in ["deepseek-ai/deepseek-r1", "google/gemma-4-31b-it", "z-ai/glm5",
1133 "qwen/qwen3.5-122b", "mistralai/mistral-small-4-119b"] {
1134 let config = PawanConfig { model: model.into(), reasoning_mode: true, ..Default::default() };
1135 assert!(config.use_thinking_mode(), "thinking mode should be on for {}", model);
1136 }
1137 }
1138
1139 #[test]
1140 fn test_thinking_mode_disabled_when_reasoning_off() {
1141 let config = PawanConfig { model: "deepseek-ai/deepseek-r1".into(), reasoning_mode: false, ..Default::default() };
1142 assert!(!config.use_thinking_mode());
1143 }
1144
1145 #[test]
1146 fn test_thinking_mode_unsupported_models() {
1147 for model in ["meta/llama-3.1-70b", "minimaxai/minimax-m2.5", "stepfun-ai/step-3.5-flash"] {
1148 let config = PawanConfig { model: model.into(), reasoning_mode: true, ..Default::default() };
1149 assert!(!config.use_thinking_mode(), "thinking mode should be off for {}", model);
1150 }
1151 }
1152
1153 #[test]
1156 fn test_system_prompt_default() {
1157 let config = PawanConfig::default();
1158 let prompt = config.get_system_prompt();
1159 assert!(prompt.contains("Pawan"), "default prompt should mention Pawan");
1160 assert!(prompt.contains("coding"), "default prompt should mention coding");
1161 }
1162
1163 #[test]
1164 fn test_system_prompt_custom_override() {
1165 let config = PawanConfig { system_prompt: Some("Custom system prompt.".into()), ..Default::default() };
1166 let prompt = config.get_system_prompt();
1167 assert!(prompt.starts_with("Custom system prompt."));
1168 }
1169
1170 #[test]
1173 fn test_config_with_cloud_fallback() {
1174 let toml = r#"
1175model = "qwen/qwen3.5-122b-a10b"
1176[cloud]
1177provider = "nvidia"
1178model = "minimaxai/minimax-m2.5"
1179"#;
1180 let config: PawanConfig = toml::from_str(toml).expect("should parse");
1181 assert_eq!(config.model, "qwen/qwen3.5-122b-a10b");
1182 let cloud = config.cloud.unwrap();
1183 assert_eq!(cloud.model, "minimaxai/minimax-m2.5");
1184 }
1185
1186 #[test]
1187 fn test_config_with_healing() {
1188 let toml = r#"
1189model = "test"
1190[healing]
1191fix_errors = true
1192fix_warnings = false
1193fix_tests = true
1194"#;
1195 let config: PawanConfig = toml::from_str(toml).expect("should parse");
1196 assert!(config.healing.fix_errors);
1197 assert!(!config.healing.fix_warnings);
1198 assert!(config.healing.fix_tests);
1199 }
1200
1201 #[test]
1202 fn test_config_defaults_sensible() {
1203 let config = PawanConfig::default();
1204 assert_eq!(config.provider, LlmProvider::Nvidia);
1205 assert!(config.temperature > 0.0 && config.temperature <= 1.0);
1206 assert!(config.max_tokens > 0);
1207 assert!(config.max_tool_iterations > 0);
1208 }
1209
1210 #[test]
1211 fn test_context_file_search_order() {
1212 let config = PawanConfig::default();
1216 let prompt = config.get_system_prompt();
1217 if std::path::Path::new("PAWAN.md").exists() {
1219 assert!(prompt.contains("Project Context"), "Should inject project context when PAWAN.md exists");
1220 assert!(prompt.contains("from PAWAN.md"), "Should identify source as PAWAN.md");
1221 }
1222 }
1223
1224 #[test]
1225 fn test_system_prompt_injection_format() {
1226 let config = PawanConfig {
1228 system_prompt: Some("Base prompt.".into()),
1229 ..Default::default()
1230 };
1231 let prompt = config.get_system_prompt();
1232 if prompt.contains("Project Context") {
1234 assert!(prompt.contains("from "), "Injection should include source filename");
1235 }
1236 }
1237
1238 #[test]
1241 fn test_resolve_skills_repo_env_var_takes_priority() {
1242 let env_dir = tempfile::TempDir::new().expect("tempdir");
1245 let cfg_dir = tempfile::TempDir::new().expect("tempdir");
1246
1247 let config = PawanConfig {
1248 skills_repo: Some(cfg_dir.path().to_path_buf()),
1249 ..Default::default()
1250 };
1251
1252 std::env::set_var("PAWAN_SKILLS_REPO", env_dir.path());
1253 let resolved = config.resolve_skills_repo();
1254 std::env::remove_var("PAWAN_SKILLS_REPO");
1255
1256 let resolved = resolved.expect("env var path should resolve to Some");
1257 assert_eq!(
1258 resolved.canonicalize().unwrap(),
1259 env_dir.path().canonicalize().unwrap(),
1260 "env var should take priority over config.skills_repo"
1261 );
1262 }
1263
1264 #[test]
1265 fn test_resolve_skills_repo_env_var_nonexistent_falls_through() {
1266 let bogus = PathBuf::from("/tmp/pawan-nonexistent-skills-repo-for-test-xyz123");
1273 assert!(!bogus.exists(), "precondition: bogus path must not exist");
1274
1275 let config = PawanConfig {
1276 skills_repo: Some(PathBuf::from("/tmp/pawan-also-nonexistent-abc789")),
1277 ..Default::default()
1278 };
1279
1280 std::env::set_var("PAWAN_SKILLS_REPO", &bogus);
1281 let resolved = config.resolve_skills_repo();
1282 std::env::remove_var("PAWAN_SKILLS_REPO");
1283
1284 if let Some(ref p) = resolved {
1286 assert_ne!(p, &bogus, "nonexistent env var path must not be returned");
1287 assert!(p.is_dir(), "any returned path must be an existing directory");
1288 }
1289 }
1290
1291 #[test]
1294 fn test_auto_discover_mcp_is_idempotent() {
1295 let mut config = PawanConfig::default();
1299
1300 let first = config.auto_discover_mcp_servers();
1301 let len_after_first = config.mcp.len();
1302
1303 let second = config.auto_discover_mcp_servers();
1304 let len_after_second = config.mcp.len();
1305
1306 assert!(
1307 second.is_empty(),
1308 "second call must discover nothing (got {:?})",
1309 second
1310 );
1311 assert_eq!(
1312 len_after_first, len_after_second,
1313 "mcp map length must not change between calls (first discovered {:?})",
1314 first
1315 );
1316 }
1317
1318 #[test]
1319 fn test_auto_discover_mcp_preserves_existing_entries() {
1320 let mut config = PawanConfig::default();
1324 let custom = McpServerEntry {
1325 command: "custom-eruka".to_string(),
1326 args: vec!["--custom-flag".to_string()],
1327 env: HashMap::new(),
1328 enabled: true,
1329 };
1330 config.mcp.insert("eruka".to_string(), custom);
1331
1332 let discovered = config.auto_discover_mcp_servers();
1333
1334 assert!(
1336 !discovered.contains(&"eruka".to_string()),
1337 "pre-existing 'eruka' entry must not be rediscovered, got {:?}",
1338 discovered
1339 );
1340
1341 let entry = config.mcp.get("eruka").expect("eruka entry must still exist");
1343 assert_eq!(entry.command, "custom-eruka", "custom command must be preserved");
1344 assert_eq!(entry.args, vec!["--custom-flag".to_string()]);
1345 }
1346
1347 #[test]
1350 fn test_discover_skills_from_repo_returns_parsed_skills() {
1351 let repo = tempfile::TempDir::new().expect("tempdir");
1354
1355 let skill_dir = repo.path().join("example-skill");
1357 std::fs::create_dir(&skill_dir).expect("mkdir example-skill");
1358 let skill_md = skill_dir.join("SKILL.md");
1359 std::fs::write(
1360 &skill_md,
1361 "---\nname: example-skill\ndescription: A test skill used in pawan unit tests\n---\n# Instructions\n\nDo the thing.\n",
1362 )
1363 .expect("write SKILL.md");
1364
1365 let empty_dir = repo.path().join("not-a-skill");
1367 std::fs::create_dir(&empty_dir).expect("mkdir not-a-skill");
1368
1369 let config = PawanConfig {
1370 skills_repo: Some(repo.path().to_path_buf()),
1371 ..Default::default()
1372 };
1373
1374 std::env::remove_var("PAWAN_SKILLS_REPO");
1376
1377 let skills = config.discover_skills_from_repo();
1378 assert_eq!(skills.len(), 1, "expected exactly 1 skill, got {:?}", skills);
1379
1380 let (name, desc, path) = &skills[0];
1381 assert_eq!(name, "example-skill");
1382 assert_eq!(desc, "A test skill used in pawan unit tests");
1383 assert_eq!(path, &skill_md);
1384 }
1385
1386 #[test]
1389 fn test_load_with_explicit_pawan_toml_path() {
1390 let tmp = tempfile::TempDir::new().expect("tempdir");
1392 let path = tmp.path().join("pawan.toml");
1393 std::fs::write(
1394 &path,
1395 r#"
1396provider = "nvidia"
1397model = "meta/llama-3.1-405b-instruct"
1398"#,
1399 )
1400 .expect("write pawan.toml");
1401
1402 let config = PawanConfig::load(Some(&path)).expect("load should succeed");
1403 assert_eq!(config.model, "meta/llama-3.1-405b-instruct");
1404 }
1405
1406 #[test]
1407 fn test_load_with_invalid_toml_returns_error() {
1408 let tmp = tempfile::TempDir::new().expect("tempdir");
1410 let path = tmp.path().join("pawan.toml");
1411 std::fs::write(&path, "this is not [[valid] toml @@").expect("write bad toml");
1412
1413 let result = PawanConfig::load(Some(&path));
1414 assert!(result.is_err(), "malformed TOML must return Err");
1415 let err_msg = format!("{}", result.unwrap_err());
1416 assert!(
1417 err_msg.to_lowercase().contains("parse")
1418 || err_msg.to_lowercase().contains("failed"),
1419 "error should mention parse/failed, got: {}",
1420 err_msg
1421 );
1422 }
1423
1424 #[test]
1425 fn test_load_with_nonexistent_path_returns_error() {
1426 let bogus = PathBuf::from("/tmp/definitely-does-not-exist-abc123-xyz.toml");
1430 let result = PawanConfig::load(Some(&bogus));
1431 assert!(
1432 result.is_err(),
1433 "non-existent explicit path must return Err"
1434 );
1435 }
1436
1437 #[test]
1438 fn test_load_ares_toml_with_pawan_section() {
1439 let tmp = tempfile::TempDir::new().expect("tempdir");
1441 let path = tmp.path().join("ares.toml");
1442 std::fs::write(
1443 &path,
1444 r#"
1445# ares config (unrelated to pawan)
1446[server]
1447port = 3000
1448
1449[pawan]
1450provider = "ollama"
1451model = "qwen3-coder:30b"
1452"#,
1453 )
1454 .expect("write ares.toml");
1455
1456 let config = PawanConfig::load(Some(&path)).expect("ares.toml load should succeed");
1457 assert_eq!(config.provider, LlmProvider::Ollama);
1458 assert_eq!(config.model, "qwen3-coder:30b");
1459 }
1460
1461 #[test]
1462 fn test_load_ares_toml_without_pawan_section_returns_defaults() {
1463 let tmp = tempfile::TempDir::new().expect("tempdir");
1467 let path = tmp.path().join("ares.toml");
1468 std::fs::write(
1469 &path,
1470 r#"
1471[server]
1472port = 3000
1473workers = 4
1474"#,
1475 )
1476 .expect("write ares.toml without pawan section");
1477
1478 let config = PawanConfig::load(Some(&path)).expect("load should succeed");
1479 let defaults = PawanConfig::default();
1481 assert_eq!(config.provider, defaults.provider);
1482 assert_eq!(config.model, defaults.model);
1483 }
1484
1485 #[test]
1486 fn test_load_empty_toml_file_returns_defaults() {
1487 let tmp = tempfile::TempDir::new().expect("tempdir");
1490 let path = tmp.path().join("pawan.toml");
1491 std::fs::write(&path, "").expect("write empty toml");
1492
1493 let config = PawanConfig::load(Some(&path)).expect("empty toml should load");
1494 let defaults = PawanConfig::default();
1495 assert_eq!(config.provider, defaults.provider);
1496 }
1497}
1498 #[test]
1499 fn test_default_config_version() {
1500 assert_eq!(default_config_version(), 1);
1501 }
1502
1503 #[test]
1504 fn test_default_tool_idle_timeout() {
1505 assert_eq!(default_tool_idle_timeout(), 300);
1506 }
1507
1508 #[test]
1509 fn test_config_version_field_exists() {
1510 let config = PawanConfig::default();
1511 assert_eq!(config.config_version, 1);
1512 }
1513
1514 #[test]
1515 fn test_tool_idle_timeout_field_exists() {
1516 let config = PawanConfig::default();
1517 assert_eq!(config.tool_call_idle_timeout_secs, 300);
1518 }
1519
1520 #[test]
1521 fn test_migration_result_fields() {
1522 let result = MigrationResult {
1523 migrated: true,
1524 from_version: 0,
1525 to_version: 1,
1526 backup_path: Some(std::path::PathBuf::from("/tmp/backup.toml")),
1527 };
1528 assert!(result.migrated);
1529 assert_eq!(result.from_version, 0);
1530 assert_eq!(result.to_version, 1);
1531 assert!(result.backup_path.is_some());
1532 }
1533
1534 #[test]
1535 fn test_migrate_to_latest_no_migration_needed() {
1536 let mut config = PawanConfig::default();
1537 config.config_version = 1; let result = migrate_to_latest(&mut config, None);
1540
1541 assert!(!result.migrated, "Should not migrate if already at latest version");
1542 assert_eq!(result.from_version, 1);
1543 assert_eq!(result.to_version, 1);
1544 }
1545
1546 #[test]
1547 fn test_migrate_to_latest_performs_migration() {
1548 let mut config = PawanConfig::default();
1549 config.config_version = 0; let result = migrate_to_latest(&mut config, None);
1552
1553 assert!(result.migrated, "Should migrate from old version");
1554 assert_eq!(result.from_version, 0);
1555 assert_eq!(result.to_version, 1);
1556 assert_eq!(config.config_version, 1, "Config version should be updated");
1557 }
1558
1559 #[test]
1560 fn test_migrate_to_v1_adds_default_fields() {
1561 let mut config = PawanConfig::default();
1562 config.config_version = 0;
1563
1564 let result = migration::migrate_to_v1(&mut config);
1565
1566 assert!(result.is_ok(), "Migration should succeed");
1567 assert_eq!(result.unwrap(), 1, "Should return new version");
1568 assert_eq!(config.config_version, 1, "Config version should be updated");
1569 }
1570
1571 #[test]
1572 fn test_migration_result_no_migration() {
1573 let result = MigrationResult::no_migration(1);
1574
1575 assert!(!result.migrated, "Should indicate no migration");
1576 assert_eq!(result.from_version, 1);
1577 assert_eq!(result.to_version, 1);
1578 assert!(result.backup_path.is_none(), "Should not have backup path");
1579 }
1580
1581 #[test]
1582 fn test_migration_result_with_backup() {
1583 let backup_path = std::path::PathBuf::from("/tmp/backup.toml");
1584 let result = MigrationResult::new(0, 1, Some(backup_path.clone()));
1585
1586 assert!(result.migrated, "Should indicate migration occurred");
1587 assert_eq!(result.from_version, 0);
1588 assert_eq!(result.to_version, 1);
1589 assert_eq!(result.backup_path, Some(backup_path), "Should have backup path");
1590 }
1591