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
19const fn default_tool_idle_timeout() -> u64 {
21 300
22}
23
24#[derive(Debug)]
26pub struct MigrationResult {
27 pub migrated: bool,
29 pub from_version: u32,
31 pub to_version: u32,
33 pub backup_path: Option<std::path::PathBuf>,
35}
36
37impl MigrationResult {
38 pub fn new(from_version: u32, to_version: u32, backup_path: Option<std::path::PathBuf>) -> Self {
40 Self {
41 migrated: from_version != to_version,
42 from_version,
43 to_version,
44 backup_path,
45 }
46 }
47
48 pub fn no_migration(version: u32) -> Self {
50 Self {
51 migrated: false,
52 from_version: version,
53 to_version: version,
54 backup_path: None,
55 }
56 }
57}
58
59const LATEST_CONFIG_VERSION: u32 = 1;
61
62pub fn migrate_to_latest(config: &mut PawanConfig, config_path: Option<&PathBuf>) -> MigrationResult {
74 let current_version = config.config_version;
75
76 if current_version >= LATEST_CONFIG_VERSION {
77 return MigrationResult::no_migration(current_version);
78 }
79
80 let backup_path = config_path.and_then(|path| create_backup(path).ok());
82
83 let mut version = current_version;
85 while version < LATEST_CONFIG_VERSION {
86 version = match migrate_to_version(config, version + 1) {
87 Ok(v) => v,
88 Err(e) => {
89 tracing::error!(
90 from_version = version,
91 to_version = LATEST_CONFIG_VERSION,
92 error = %e,
93 "Config migration failed"
94 );
95 return MigrationResult::new(current_version, version, backup_path);
96 }
97 };
98 }
99
100 config.config_version = LATEST_CONFIG_VERSION;
101 MigrationResult::new(current_version, LATEST_CONFIG_VERSION, backup_path)
102}
103
104fn migrate_to_version(config: &mut PawanConfig, target_version: u32) -> Result<u32, String> {
113 match target_version {
114 1 => migrate_to_v1(config),
115 _ => Err(format!("Unknown target version: {}", target_version)),
116 }
117}
118
119fn migrate_to_v1(config: &mut PawanConfig) -> Result<u32, String> {
128 config.config_version = 1;
130
131 if config.tool_call_idle_timeout_secs == 0 {
133 config.tool_call_idle_timeout_secs = default_tool_idle_timeout();
134 }
135
136 tracing::info!("Config migrated to version 1");
140 Ok(1)
141}
142
143fn create_backup(config_path: &PathBuf) -> Result<PathBuf, String> {
151 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
152 let backup_path = config_path.with_extension(format!("toml.backup.{}", timestamp));
153
154 std::fs::copy(config_path, &backup_path).map_err(|e| {
155 format!("Failed to create backup at {}: {}", backup_path.display(), e)
156 })?;
157
158 tracing::info!(backup = %backup_path.display(), "Config backup created");
159 Ok(backup_path)
160}
161
162pub fn save_config(config: &PawanConfig, path: &PathBuf) -> Result<(), String> {
171 let toml_string = toml::to_string_pretty(config).map_err(|e| {
172 format!("Failed to serialize config to TOML: {}", e)
173 })?;
174
175 std::fs::write(path, toml_string).map_err(|e| {
176 format!("Failed to write config to {}: {}", path.display(), e)
177 })?;
178
179 tracing::info!(path = %path.display(), "Config saved");
180 Ok(())
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
185#[serde(rename_all = "lowercase")]
186pub enum LlmProvider {
187 #[default]
189 Nvidia,
190 Ollama,
192 OpenAI,
194 Mlx,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
200#[serde(default)]
201pub struct PawanConfig {
202 #[serde(default = "default_config_version")]
204 pub config_version: u32,
205
206 pub provider: LlmProvider,
208
209 pub model: String,
211
212 pub base_url: Option<String>,
215
216 pub dry_run: bool,
218
219 pub auto_backup: bool,
221
222 pub require_git_clean: bool,
224
225 pub bash_timeout_secs: u64,
227
228 #[serde(default = "default_tool_idle_timeout")]
231 pub tool_call_idle_timeout_secs: u64,
232
233 pub max_file_size_kb: usize,
235
236 pub max_tool_iterations: usize,
238 pub max_context_tokens: usize,
240
241 pub system_prompt: Option<String>,
243
244 pub temperature: f32,
246
247 pub top_p: f32,
249
250 pub max_tokens: usize,
252
253 pub thinking_budget: usize,
257
258 pub max_retries: usize,
260
261 pub fallback_models: Vec<String>,
263 pub max_result_chars: usize,
265
266 pub reasoning_mode: bool,
268
269 pub healing: HealingConfig,
271
272 pub targets: HashMap<String, TargetConfig>,
274
275 pub tui: TuiConfig,
277
278 #[serde(default)]
280 pub mcp: HashMap<String, McpServerEntry>,
281
282 #[serde(default)]
284 pub permissions: HashMap<String, ToolPermission>,
285
286 pub cloud: Option<CloudConfig>,
289
290 #[serde(default)]
293 pub models: ModelRouting,
294
295 #[serde(default)]
297 pub eruka: crate::eruka_bridge::ErukaConfig,
298
299 #[serde(default)]
305 pub use_ares_backend: bool,
306 #[serde(default)]
311 pub use_coordinator: bool,
312
313 #[serde(default)]
326 pub skills_repo: Option<PathBuf>,
327
328 #[serde(default)]
335 pub local_first: bool,
336
337 #[serde(default)]
341 pub local_endpoint: Option<String>,
342}
343
344#[derive(Debug, Clone, Default, Serialize, Deserialize)]
354pub struct ModelRouting {
355 pub code: Option<String>,
357 pub orchestrate: Option<String>,
359 pub execute: Option<String>,
361}
362
363impl ModelRouting {
364 pub fn route(&self, query: &str) -> Option<&str> {
367 let q = query.to_lowercase();
368
369 if self.code.is_some() {
371 let code_signals = ["implement", "write", "create", "refactor", "fix", "add test",
372 "add function", "struct", "enum", "trait", "algorithm", "data structure"];
373 if code_signals.iter().any(|s| q.contains(s)) {
374 return self.code.as_deref();
375 }
376 }
377
378 if self.orchestrate.is_some() {
380 let orch_signals = ["search", "find", "analyze", "review", "explain", "compare",
381 "list", "check", "verify", "diagnose", "audit"];
382 if orch_signals.iter().any(|s| q.contains(s)) {
383 return self.orchestrate.as_deref();
384 }
385 }
386
387 if self.execute.is_some() {
389 let exec_signals = ["run", "execute", "bash", "cargo", "test", "build",
390 "deploy", "install", "commit"];
391 if exec_signals.iter().any(|s| q.contains(s)) {
392 return self.execute.as_deref();
393 }
394 }
395
396 None
397 }
398}
399
400#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct CloudConfig {
417 pub provider: LlmProvider,
419 pub model: String,
421 #[serde(default)]
423 pub fallback_models: Vec<String>,
424}
425
426#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
428#[serde(rename_all = "lowercase")]
429pub enum ToolPermission {
430 Allow,
432 Deny,
434 Prompt,
436}
437
438impl ToolPermission {
439 pub fn resolve(name: &str, permissions: &HashMap<String, ToolPermission>) -> Self {
444 if let Some(p) = permissions.get(name) {
445 return p.clone();
446 }
447 match name {
449 "bash" | "git_commit" | "write_file" | "edit_file_lines"
450 | "insert_after" | "append_file" => ToolPermission::Allow, _ => ToolPermission::Allow,
452 }
453 }
454}
455
456impl Default for PawanConfig {
457 fn default() -> Self {
458 let mut targets = HashMap::new();
459 targets.insert(
460 "self".to_string(),
461 TargetConfig {
462 path: PathBuf::from("."),
463 description: "Current project codebase".to_string(),
464 },
465 );
466
467 Self {
468 provider: LlmProvider::Nvidia,
469 config_version: default_config_version(),
470 model: crate::DEFAULT_MODEL.to_string(),
471 base_url: None,
472 dry_run: false,
473 auto_backup: true,
474 require_git_clean: false,
475 bash_timeout_secs: crate::DEFAULT_BASH_TIMEOUT,
476 tool_call_idle_timeout_secs: default_tool_idle_timeout(),
477 max_file_size_kb: 1024,
478 max_tool_iterations: crate::MAX_TOOL_ITERATIONS,
479 max_context_tokens: 100000,
480 system_prompt: None,
481 temperature: 1.0,
482 top_p: 0.95,
483 max_tokens: 8192,
484 thinking_budget: 0, reasoning_mode: true,
486 max_retries: 3,
487 fallback_models: Vec::new(),
488 max_result_chars: 8000,
489 healing: HealingConfig::default(),
490 targets,
491 tui: TuiConfig::default(),
492 mcp: HashMap::new(),
493 permissions: HashMap::new(),
494 cloud: None,
495 models: ModelRouting::default(),
496 eruka: crate::eruka_bridge::ErukaConfig::default(),
497 use_ares_backend: false,
498 use_coordinator: false,
499 skills_repo: None,
500 local_first: false,
501 local_endpoint: None,
502 }
503 }
504}
505
506#[derive(Debug, Clone, Serialize, Deserialize)]
508#[serde(default)]
509pub struct HealingConfig {
510 pub auto_commit: bool,
512
513 pub fix_errors: bool,
515
516 pub fix_warnings: bool,
518
519 pub fix_tests: bool,
521
522 pub generate_docs: bool,
524
525 #[serde(default)]
529 pub fix_security: bool,
530
531 pub max_attempts: usize,
533
534 #[serde(default)]
541 pub verify_cmd: Option<String>,
542}
543
544impl Default for HealingConfig {
545 fn default() -> Self {
546 Self {
547 auto_commit: false,
548 fix_errors: true,
549 fix_warnings: true,
550 fix_tests: true,
551 generate_docs: false,
552 fix_security: false,
553 max_attempts: 3,
554 verify_cmd: None,
555 }
556 }
557}
558
559#[derive(Debug, Clone, Serialize, Deserialize)]
561pub struct TargetConfig {
566 pub path: PathBuf,
568
569 pub description: String,
571}
572
573#[derive(Debug, Clone, Serialize, Deserialize)]
575#[serde(default)]
576pub struct TuiConfig {
577 pub syntax_highlighting: bool,
579
580 pub theme: String,
582
583 pub line_numbers: bool,
585
586 pub mouse_support: bool,
588
589 pub scroll_speed: usize,
591
592 pub max_history: usize,
594
595 pub auto_save_enabled: bool,
597 pub auto_save_interval_minutes: u32,
599 pub auto_save_dir: Option<std::path::PathBuf>,
601}
602
603impl Default for TuiConfig {
604 fn default() -> Self {
605 Self {
606 syntax_highlighting: true,
607 theme: "base16-ocean.dark".to_string(),
608 line_numbers: true,
609 mouse_support: true,
610 scroll_speed: 3,
611 max_history: 1000,
612 auto_save_enabled: true,
613 auto_save_interval_minutes: 5,
614 auto_save_dir: None,
615 }
616 }
617}
618
619#[derive(Debug, Clone, Serialize, Deserialize)]
621pub struct McpServerEntry {
627 pub command: String,
629 #[serde(default)]
631 pub args: Vec<String>,
632 #[serde(default)]
634 pub env: HashMap<String, String>,
635 #[serde(default = "default_true")]
637 pub enabled: bool,
638}
639
640fn default_true() -> bool {
641 true
642}
643
644impl PawanConfig {
645 pub fn load(path: Option<&PathBuf>) -> crate::Result<Self> {
647 let config_path = path.cloned().or_else(|| {
648 let pawan_toml = PathBuf::from("pawan.toml");
650 if pawan_toml.exists() {
651 return Some(pawan_toml);
652 }
653
654 let ares_toml = PathBuf::from("ares.toml");
656 if ares_toml.exists() {
657 return Some(ares_toml);
658 }
659
660 if let Some(home) = dirs::home_dir() {
662 let global = home.join(".config/pawan/pawan.toml");
663 if global.exists() {
664 return Some(global);
665 }
666 }
667
668 None
669 });
670
671 match config_path {
672 Some(path) => {
673 let content = std::fs::read_to_string(&path).map_err(|e| {
674 crate::PawanError::Config(format!("Failed to read {}: {}", path.display(), e))
675 })?;
676
677 if path.file_name().map(|n| n == "ares.toml").unwrap_or(false) {
679 let value: toml::Value = toml::from_str(&content).map_err(|e| {
681 crate::PawanError::Config(format!(
682 "Failed to parse {}: {}",
683 path.display(),
684 e
685 ))
686 })?;
687
688 if let Some(pawan_section) = value.get("pawan") {
689 let config: PawanConfig =
690 pawan_section.clone().try_into().map_err(|e| {
691 crate::PawanError::Config(format!(
692 "Failed to parse [pawan] section: {}",
693 e
694 ))
695 })?;
696 return Ok(config);
697 }
698
699 Ok(Self::default())
701 } else {
702 let mut config: PawanConfig = toml::from_str(&content).map_err(|e| {
704 crate::PawanError::Config(format!(
705 "Failed to parse {}: {}",
706 path.display(),
707 e
708 ))
709 })?;
710
711 let migration_result = migrate_to_latest(&mut config, Some(&path));
713 if migration_result.migrated {
714 tracing::info!(
715 from_version = migration_result.from_version,
716 to_version = migration_result.to_version,
717 backup = ?migration_result.backup_path,
718 "Config migrated"
719 );
720
721 if let Err(e) = save_config(&config, &path) {
723 tracing::warn!(error = %e, "Failed to save migrated config");
724 }
725 }
726
727 Ok(config)
728 }
729 }
730 None => Ok(Self::default()),
731 }
732 }
733
734 pub fn apply_env_overrides(&mut self) {
736 if let Ok(model) = std::env::var("PAWAN_MODEL") {
737 self.model = model;
738 }
739 if let Ok(provider) = std::env::var("PAWAN_PROVIDER") {
740 match provider.to_lowercase().as_str() {
741 "nvidia" | "nim" => self.provider = LlmProvider::Nvidia,
742 "ollama" => self.provider = LlmProvider::Ollama,
743 "openai" => self.provider = LlmProvider::OpenAI,
744 "mlx" | "mlx-lm" => self.provider = LlmProvider::Mlx,
745 _ => tracing::warn!(provider = provider.as_str(), "Unknown PAWAN_PROVIDER, ignoring"),
746 }
747 }
748 if let Ok(temp) = std::env::var("PAWAN_TEMPERATURE") {
749 if let Ok(t) = temp.parse::<f32>() {
750 self.temperature = t;
751 }
752 }
753 if let Ok(tokens) = std::env::var("PAWAN_MAX_TOKENS") {
754 if let Ok(t) = tokens.parse::<usize>() {
755 self.max_tokens = t;
756 }
757 }
758 if let Ok(iters) = std::env::var("PAWAN_MAX_ITERATIONS") {
759 if let Ok(i) = iters.parse::<usize>() {
760 self.max_tool_iterations = i;
761 }
762 }
763 if let Ok(ctx) = std::env::var("PAWAN_MAX_CONTEXT_TOKENS") {
764 if let Ok(c) = ctx.parse::<usize>() {
765 self.max_context_tokens = c;
766 }
767 }
768 if let Ok(models) = std::env::var("PAWAN_FALLBACK_MODELS") {
769 self.fallback_models = models.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
770 }
771 if let Ok(chars) = std::env::var("PAWAN_MAX_RESULT_CHARS") {
772 if let Ok(c) = chars.parse::<usize>() {
773 self.max_result_chars = c;
774 }
775 }
776 }
777
778 pub fn get_target(&self, name: &str) -> Option<&TargetConfig> {
780 self.targets.get(name)
781 }
782
783 pub fn get_system_prompt(&self) -> String {
786 let base = self
787 .system_prompt
788 .clone()
789 .unwrap_or_else(|| DEFAULT_SYSTEM_PROMPT.to_string());
790
791 let mut prompt = base;
792
793 if let Some((filename, ctx)) = Self::load_context_file() {
794 prompt = format!("{}\n\n## Project Context (from {})\n\n{}", prompt, filename, ctx);
795 }
796
797 if let Some(skill_ctx) = Self::load_skill_context() {
798 prompt = format!("{}\n\n## Active Skill (from SKILL.md)\n\n{}", prompt, skill_ctx);
799 }
800
801 prompt
802 }
803
804 fn load_context_file() -> Option<(String, String)> {
808 for path in &["PAWAN.md", "AGENTS.md", "CLAUDE.md", ".pawan/context.md"] {
809 let p = PathBuf::from(path);
810 if p.exists() {
811 if let Ok(content) = std::fs::read_to_string(&p) {
812 if !content.trim().is_empty() {
813 return Some((path.to_string(), content));
814 }
815 }
816 }
817 }
818 None
819 }
820
821 fn load_skill_context() -> Option<String> {
825 use thulp_skill_files::SkillFile;
826
827 let skill_path = std::path::Path::new("SKILL.md");
828 if !skill_path.exists() {
829 return None;
830 }
831
832 match SkillFile::parse(skill_path) {
833 Ok(skill) => {
834 let name = skill.effective_name();
835 let desc = skill.frontmatter.description.as_deref().unwrap_or("no description");
836 let tools_str = match &skill.frontmatter.allowed_tools {
837 Some(tools) => tools.join(", "),
838 None => "all".to_string(),
839 };
840 Some(format!(
841 "[Skill: {}] {}\nAllowed tools: {}\n---\n{}",
842 name, desc, tools_str, skill.content
843 ))
844 }
845 Err(e) => {
846 tracing::warn!("Failed to parse SKILL.md: {}", e);
847 None
848 }
849 }
850 }
851
852 pub fn resolve_skills_repo(&self) -> Option<PathBuf> {
859 if let Ok(env_path) = std::env::var("PAWAN_SKILLS_REPO") {
861 let p = PathBuf::from(env_path);
862 if p.is_dir() {
863 return Some(p);
864 }
865 tracing::warn!(path = %p.display(), "PAWAN_SKILLS_REPO set but directory does not exist");
866 }
867
868 if let Some(ref p) = self.skills_repo {
870 if p.is_dir() {
871 return Some(p.clone());
872 }
873 tracing::warn!(path = %p.display(), "config.skills_repo set but directory does not exist");
874 }
875
876 if let Some(home) = dirs::home_dir() {
878 let default = home.join(".config").join("pawan").join("skills");
879 if default.is_dir() {
880 return Some(default);
881 }
882 }
883
884 None
885 }
886
887 pub fn auto_discover_mcp_servers(&mut self) -> Vec<String> {
898 let mut discovered = Vec::new();
899
900 if !self.mcp.contains_key("eruka") && which::which("eruka-mcp").is_ok() {
902 self.mcp.insert(
903 "eruka".to_string(),
904 McpServerEntry {
905 command: "eruka-mcp".to_string(),
906 args: vec!["--transport".to_string(), "stdio".to_string()],
907 env: HashMap::new(),
908 enabled: true,
909 },
910 );
911 discovered.push("eruka".to_string());
912 tracing::info!("auto-discovered eruka-mcp");
913 }
914
915 if !self.mcp.contains_key("daedra") && which::which("daedra").is_ok() {
917 self.mcp.insert(
918 "daedra".to_string(),
919 McpServerEntry {
920 command: "daedra".to_string(),
921 args: vec![
922 "serve".to_string(),
923 "--transport".to_string(),
924 "stdio".to_string(),
925 "--quiet".to_string(),
926 ],
927 env: HashMap::new(),
928 enabled: true,
929 },
930 );
931 discovered.push("daedra".to_string());
932 tracing::info!("auto-discovered daedra");
933 }
934
935 if !self.mcp.contains_key("deagle") && which::which("deagle-mcp").is_ok() {
937 self.mcp.insert(
938 "deagle".to_string(),
939 McpServerEntry {
940 command: "deagle-mcp".to_string(),
941 args: vec!["--transport".to_string(), "stdio".to_string()],
942 env: HashMap::new(),
943 enabled: true,
944 },
945 );
946 discovered.push("deagle".to_string());
947 tracing::info!("auto-discovered deagle-mcp");
948 }
949
950 discovered
951 }
952
953 pub fn discover_skills_from_repo(&self) -> Vec<(String, String, PathBuf)> {
964 use thulp_skill_files::SkillFile;
965
966 let repo = match self.resolve_skills_repo() {
967 Some(r) => r,
968 None => return Vec::new(),
969 };
970
971 let mut results = Vec::new();
972 let walker = match std::fs::read_dir(&repo) {
973 Ok(w) => w,
974 Err(e) => {
975 tracing::warn!(path = %repo.display(), error = %e, "failed to read skills repo");
976 return Vec::new();
977 }
978 };
979
980 for entry in walker.flatten() {
981 let path = entry.path();
982 let skill_file = path.join("SKILL.md");
984 if !skill_file.is_file() {
985 continue;
986 }
987 match SkillFile::parse(&skill_file) {
988 Ok(skill) => {
989 let name = skill.effective_name();
990 let desc = skill
991 .frontmatter
992 .description
993 .clone()
994 .unwrap_or_else(|| "(no description)".to_string());
995 results.push((name, desc, skill_file));
996 }
997 Err(e) => {
998 tracing::debug!(path = %skill_file.display(), error = %e, "skip unparseable skill");
999 }
1000 }
1001 }
1002
1003 results.sort_by(|a, b| a.0.cmp(&b.0));
1004 results
1005 }
1006
1007 pub fn use_thinking_mode(&self) -> bool {
1010 self.reasoning_mode
1011 && (self.model.contains("deepseek")
1012 || self.model.contains("gemma")
1013 || self.model.contains("glm")
1014 || self.model.contains("qwen")
1015 || self.model.contains("mistral-small-4"))
1016 }
1017}
1018
1019pub const DEFAULT_SYSTEM_PROMPT: &str = r#"You are Pawan, an expert coding assistant.
1021
1022# Efficiency
1023- Act immediately. Do NOT explore or plan before writing. Write code FIRST, then verify.
1024- write_file creates parents automatically. No mkdir needed.
1025- cargo check runs automatically after .rs writes — fix errors immediately.
1026- Use relative paths from workspace root.
1027- Missing tools are auto-installed via mise. Don't check dependencies.
1028- You have limited tool iterations. Be direct. No preamble.
1029
1030# Tool Selection
1031Use the BEST tool for the job — do NOT use bash for things dedicated tools handle:
1032- File ops: read_file, write_file, edit_file, edit_file_lines, insert_after, append_file, list_directory
1033- Code intelligence: ast_grep (AST search + rewrite via tree-sitter — prefer for structural changes)
1034- Search: glob_search (files by pattern), grep_search (content by regex), ripgrep (native rg), fd (native find)
1035- Shell: bash (commands), sd (find-replace in files), mise (tool/task/env manager), zoxide (smart cd)
1036- Git: git_status, git_diff, git_add, git_commit, git_log, git_blame, git_branch, git_checkout, git_stash
1037- Agent: spawn_agent (delegate subtask), spawn_agents (parallel sub-agents)
1038- Web: mcp_daedra_web_search (ALWAYS use for web queries — never bash+curl)
1039
1040Prefer ast_grep over edit_file for code refactors. Prefer grep_search over bash grep.
1041Prefer fd over bash find. Prefer sd over bash sed.
1042
1043# Parallel Execution
1044Call multiple tools in a single response when they are independent.
1045If tool B depends on tool A's result, call them sequentially.
1046Never parallelize destructive operations (writes, deletes, commits).
1047
1048# Read Before Modifying
1049Do NOT propose changes to code you haven't read. If asked to modify a file, read it first.
1050Understand existing code, patterns, and style before suggesting changes.
1051
1052# Scope Discipline
1053Make minimal, focused changes. Follow existing code style.
1054- Don't add features, refactor, or "improve" code beyond what was asked.
1055- Don't add docstrings, comments, or type annotations to code you didn't change.
1056- A bug fix doesn't need surrounding code cleaned up.
1057- Don't add error handling for scenarios that can't happen.
1058
1059# Executing Actions with Care
1060Consider reversibility and blast radius before acting:
1061- Freely take local, reversible actions (editing files, running tests).
1062- For hard-to-reverse actions (force-push, rm -rf, dropping tables), ask first.
1063- Match the scope of your actions to what was requested.
1064- Investigate before deleting — unfamiliar files may be the user's in-progress work.
1065- Don't use destructive shortcuts to bypass safety checks.
1066
1067# Git Safety
1068- NEVER skip hooks (--no-verify) unless explicitly asked.
1069- ALWAYS create NEW commits rather than amending (amend after hook failure destroys work).
1070- NEVER force-push to main/master. Warn if requested.
1071- Prefer staging specific files over `git add -A` (avoids committing secrets).
1072- Only commit when explicitly asked. Don't be over-eager.
1073- Commit messages: focus on WHY, not WHAT. Use HEREDOC for multi-line messages.
1074- Use the git author from `git config user.name` / `git config user.email`.
1075
1076# Output Style
1077Be concise. Lead with the answer, not the reasoning.
1078Focus text output on: decisions needing input, status updates, errors/blockers.
1079If you can say it in one sentence, don't use three.
1080After .rs writes, cargo check auto-runs — fix errors immediately if it fails.
1081Run tests when the task calls for it (cargo test -p <crate>).
1082One fix at a time. If it doesn't work, try a different approach."#;
1083
1084#[cfg(test)]
1085mod tests {
1086 use super::*;
1087
1088 #[test]
1089 fn test_provider_mlx_parsing() {
1090 let toml = r#"
1092provider = "mlx"
1093model = "mlx-community/Qwen3.5-9B-4bit"
1094"#;
1095 let config: PawanConfig = toml::from_str(toml).expect("should parse without error");
1096 assert_eq!(config.provider, LlmProvider::Mlx);
1097 assert_eq!(config.model, "mlx-community/Qwen3.5-9B-4bit");
1098 }
1099
1100 #[test]
1101 fn test_provider_mlx_lm_alias() {
1102 let mut config = PawanConfig::default();
1104 std::env::set_var("PAWAN_PROVIDER", "mlx-lm");
1105 config.apply_env_overrides();
1106 std::env::remove_var("PAWAN_PROVIDER");
1107 assert_eq!(config.provider, LlmProvider::Mlx);
1108 }
1109
1110 #[test]
1111 fn test_mlx_base_url_override() {
1112 let toml = r#"
1114provider = "mlx"
1115model = "test-model"
1116base_url = "http://192.168.1.100:8080/v1"
1117"#;
1118 let config: PawanConfig = toml::from_str(toml).expect("should parse without error");
1119 assert_eq!(config.provider, LlmProvider::Mlx);
1120 assert_eq!(
1121 config.base_url.as_deref(),
1122 Some("http://192.168.1.100:8080/v1")
1123 );
1124 }
1125
1126 #[test]
1129 fn test_route_code_signals() {
1130 let routing = ModelRouting {
1131 code: Some("code-model".into()),
1132 orchestrate: Some("orch-model".into()),
1133 execute: Some("exec-model".into()),
1134 };
1135 assert_eq!(routing.route("implement a linked list"), Some("code-model"));
1136 assert_eq!(routing.route("refactor the parser"), Some("code-model"));
1137 assert_eq!(routing.route("add test for config"), Some("code-model"));
1138 assert_eq!(routing.route("Write a new struct"), Some("code-model"));
1139 }
1140
1141 #[test]
1142 fn test_route_orchestration_signals() {
1143 let routing = ModelRouting {
1144 code: Some("code-model".into()),
1145 orchestrate: Some("orch-model".into()),
1146 execute: Some("exec-model".into()),
1147 };
1148 assert_eq!(routing.route("analyze the error logs"), Some("orch-model"));
1149 assert_eq!(routing.route("review this PR"), Some("orch-model"));
1150 assert_eq!(routing.route("explain how the agent works"), Some("orch-model"));
1151 assert_eq!(routing.route("search for uses of foo"), Some("orch-model"));
1152 }
1153
1154 #[test]
1155 fn test_route_execution_signals() {
1156 let routing = ModelRouting {
1157 code: Some("code-model".into()),
1158 orchestrate: Some("orch-model".into()),
1159 execute: Some("exec-model".into()),
1160 };
1161 assert_eq!(routing.route("run cargo test"), Some("exec-model"));
1162 assert_eq!(routing.route("execute the deploy script"), Some("exec-model"));
1163 assert_eq!(routing.route("build the project"), Some("exec-model"));
1164 assert_eq!(routing.route("commit these changes"), Some("exec-model"));
1165 }
1166
1167 #[test]
1168 fn test_route_no_match_returns_none() {
1169 let routing = ModelRouting {
1170 code: Some("code-model".into()),
1171 orchestrate: Some("orch-model".into()),
1172 execute: Some("exec-model".into()),
1173 };
1174 assert_eq!(routing.route("hello world"), None);
1175 }
1176
1177 #[test]
1178 fn test_route_empty_routing_returns_none() {
1179 let routing = ModelRouting::default();
1180 assert_eq!(routing.route("implement something"), None);
1181 assert_eq!(routing.route("search for bugs"), None);
1182 }
1183
1184 #[test]
1185 fn test_route_case_insensitive() {
1186 let routing = ModelRouting {
1187 code: Some("code-model".into()),
1188 orchestrate: None,
1189 execute: None,
1190 };
1191 assert_eq!(routing.route("IMPLEMENT a FUNCTION"), Some("code-model"));
1192 }
1193
1194 #[test]
1195 fn test_route_partial_routing() {
1196 let routing = ModelRouting {
1198 code: Some("code-model".into()),
1199 orchestrate: None,
1200 execute: None,
1201 };
1202 assert_eq!(routing.route("implement x"), Some("code-model"));
1203 assert_eq!(routing.route("search for y"), None);
1204 assert_eq!(routing.route("run tests"), None);
1205 }
1206
1207 #[test]
1210 fn test_env_override_model() {
1211 let mut config = PawanConfig::default();
1212 std::env::set_var("PAWAN_MODEL", "custom/model-123");
1213 config.apply_env_overrides();
1214 std::env::remove_var("PAWAN_MODEL");
1215 assert_eq!(config.model, "custom/model-123");
1216 }
1217
1218 #[test]
1219 fn test_env_override_temperature() {
1220 let mut config = PawanConfig::default();
1221 std::env::set_var("PAWAN_TEMPERATURE", "0.9");
1222 config.apply_env_overrides();
1223 std::env::remove_var("PAWAN_TEMPERATURE");
1224 assert!((config.temperature - 0.9).abs() < f32::EPSILON);
1225 }
1226
1227 #[test]
1228 fn test_env_override_invalid_temperature_ignored() {
1229 let mut config = PawanConfig::default();
1230 let original = config.temperature;
1231 std::env::set_var("PAWAN_TEMPERATURE", "not_a_number");
1232 config.apply_env_overrides();
1233 std::env::remove_var("PAWAN_TEMPERATURE");
1234 assert!((config.temperature - original).abs() < f32::EPSILON);
1235 }
1236
1237 #[test]
1238 fn test_env_override_max_tokens() {
1239 let mut config = PawanConfig::default();
1240 std::env::set_var("PAWAN_MAX_TOKENS", "16384");
1241 config.apply_env_overrides();
1242 std::env::remove_var("PAWAN_MAX_TOKENS");
1243 assert_eq!(config.max_tokens, 16384);
1244 }
1245
1246 #[test]
1247 fn test_env_override_fallback_models() {
1248 let mut config = PawanConfig::default();
1249 std::env::set_var("PAWAN_FALLBACK_MODELS", "model-a, model-b, model-c");
1250 config.apply_env_overrides();
1251 std::env::remove_var("PAWAN_FALLBACK_MODELS");
1252 assert_eq!(config.fallback_models, vec!["model-a", "model-b", "model-c"]);
1253 }
1254
1255 #[test]
1256 fn test_env_override_fallback_models_filters_empty() {
1257 let mut config = PawanConfig::default();
1258 std::env::set_var("PAWAN_FALLBACK_MODELS", "model-a,,, model-b,");
1259 config.apply_env_overrides();
1260 std::env::remove_var("PAWAN_FALLBACK_MODELS");
1261 assert_eq!(config.fallback_models, vec!["model-a", "model-b"]);
1262 }
1263
1264 #[test]
1265 fn test_env_override_provider_variants() {
1266 for (env_val, expected) in [
1267 ("nvidia", LlmProvider::Nvidia),
1268 ("nim", LlmProvider::Nvidia),
1269 ("ollama", LlmProvider::Ollama),
1270 ("openai", LlmProvider::OpenAI),
1271 ("mlx", LlmProvider::Mlx),
1272 ] {
1273 let mut config = PawanConfig::default();
1274 std::env::set_var("PAWAN_PROVIDER", env_val);
1275 config.apply_env_overrides();
1276 std::env::remove_var("PAWAN_PROVIDER");
1277 assert_eq!(config.provider, expected, "PAWAN_PROVIDER={} should map to {:?}", env_val, expected);
1278 }
1279 }
1280
1281 #[test]
1284 fn test_thinking_mode_supported_models() {
1285 for model in ["deepseek-ai/deepseek-r1", "google/gemma-4-31b-it", "z-ai/glm5",
1286 "qwen/qwen3.5-122b", "mistralai/mistral-small-4-119b"] {
1287 let config = PawanConfig { model: model.into(), reasoning_mode: true, ..Default::default() };
1288 assert!(config.use_thinking_mode(), "thinking mode should be on for {}", model);
1289 }
1290 }
1291
1292 #[test]
1293 fn test_thinking_mode_disabled_when_reasoning_off() {
1294 let config = PawanConfig { model: "deepseek-ai/deepseek-r1".into(), reasoning_mode: false, ..Default::default() };
1295 assert!(!config.use_thinking_mode());
1296 }
1297
1298 #[test]
1299 fn test_thinking_mode_unsupported_models() {
1300 for model in ["meta/llama-3.1-70b", "minimaxai/minimax-m2.5", "stepfun-ai/step-3.5-flash"] {
1301 let config = PawanConfig { model: model.into(), reasoning_mode: true, ..Default::default() };
1302 assert!(!config.use_thinking_mode(), "thinking mode should be off for {}", model);
1303 }
1304 }
1305
1306 #[test]
1309 fn test_system_prompt_default() {
1310 let config = PawanConfig::default();
1311 let prompt = config.get_system_prompt();
1312 assert!(prompt.contains("Pawan"), "default prompt should mention Pawan");
1313 assert!(prompt.contains("coding"), "default prompt should mention coding");
1314 }
1315
1316 #[test]
1317 fn test_system_prompt_custom_override() {
1318 let config = PawanConfig { system_prompt: Some("Custom system prompt.".into()), ..Default::default() };
1319 let prompt = config.get_system_prompt();
1320 assert!(prompt.starts_with("Custom system prompt."));
1321 }
1322
1323 #[test]
1326 fn test_config_with_cloud_fallback() {
1327 let toml = r#"
1328model = "qwen/qwen3.5-122b-a10b"
1329[cloud]
1330provider = "nvidia"
1331model = "minimaxai/minimax-m2.5"
1332"#;
1333 let config: PawanConfig = toml::from_str(toml).expect("should parse");
1334 assert_eq!(config.model, "qwen/qwen3.5-122b-a10b");
1335 let cloud = config.cloud.unwrap();
1336 assert_eq!(cloud.model, "minimaxai/minimax-m2.5");
1337 }
1338
1339 #[test]
1340 fn test_config_with_healing() {
1341 let toml = r#"
1342model = "test"
1343[healing]
1344fix_errors = true
1345fix_warnings = false
1346fix_tests = true
1347"#;
1348 let config: PawanConfig = toml::from_str(toml).expect("should parse");
1349 assert!(config.healing.fix_errors);
1350 assert!(!config.healing.fix_warnings);
1351 assert!(config.healing.fix_tests);
1352 }
1353
1354 #[test]
1355 fn test_config_defaults_sensible() {
1356 let config = PawanConfig::default();
1357 assert_eq!(config.provider, LlmProvider::Nvidia);
1358 assert!(config.temperature > 0.0 && config.temperature <= 1.0);
1359 assert!(config.max_tokens > 0);
1360 assert!(config.max_tool_iterations > 0);
1361 }
1362
1363 #[test]
1364 fn test_context_file_search_order() {
1365 let config = PawanConfig::default();
1369 let prompt = config.get_system_prompt();
1370 if std::path::Path::new("PAWAN.md").exists() {
1372 assert!(prompt.contains("Project Context"), "Should inject project context when PAWAN.md exists");
1373 assert!(prompt.contains("from PAWAN.md"), "Should identify source as PAWAN.md");
1374 }
1375 }
1376
1377 #[test]
1378 fn test_system_prompt_injection_format() {
1379 let config = PawanConfig {
1381 system_prompt: Some("Base prompt.".into()),
1382 ..Default::default()
1383 };
1384 let prompt = config.get_system_prompt();
1385 if prompt.contains("Project Context") {
1387 assert!(prompt.contains("from "), "Injection should include source filename");
1388 }
1389 }
1390
1391 #[test]
1394 fn test_resolve_skills_repo_env_var_takes_priority() {
1395 let env_dir = tempfile::TempDir::new().expect("tempdir");
1398 let cfg_dir = tempfile::TempDir::new().expect("tempdir");
1399
1400 let config = PawanConfig {
1401 skills_repo: Some(cfg_dir.path().to_path_buf()),
1402 ..Default::default()
1403 };
1404
1405 std::env::set_var("PAWAN_SKILLS_REPO", env_dir.path());
1406 let resolved = config.resolve_skills_repo();
1407 std::env::remove_var("PAWAN_SKILLS_REPO");
1408
1409 let resolved = resolved.expect("env var path should resolve to Some");
1410 assert_eq!(
1411 resolved.canonicalize().unwrap(),
1412 env_dir.path().canonicalize().unwrap(),
1413 "env var should take priority over config.skills_repo"
1414 );
1415 }
1416
1417 #[test]
1418 fn test_resolve_skills_repo_env_var_nonexistent_falls_through() {
1419 let bogus = PathBuf::from("/tmp/pawan-nonexistent-skills-repo-for-test-xyz123");
1426 assert!(!bogus.exists(), "precondition: bogus path must not exist");
1427
1428 let config = PawanConfig {
1429 skills_repo: Some(PathBuf::from("/tmp/pawan-also-nonexistent-abc789")),
1430 ..Default::default()
1431 };
1432
1433 std::env::set_var("PAWAN_SKILLS_REPO", &bogus);
1434 let resolved = config.resolve_skills_repo();
1435 std::env::remove_var("PAWAN_SKILLS_REPO");
1436
1437 if let Some(ref p) = resolved {
1439 assert_ne!(p, &bogus, "nonexistent env var path must not be returned");
1440 assert!(p.is_dir(), "any returned path must be an existing directory");
1441 }
1442 }
1443
1444 #[test]
1447 fn test_auto_discover_mcp_is_idempotent() {
1448 let mut config = PawanConfig::default();
1452
1453 let first = config.auto_discover_mcp_servers();
1454 let len_after_first = config.mcp.len();
1455
1456 let second = config.auto_discover_mcp_servers();
1457 let len_after_second = config.mcp.len();
1458
1459 assert!(
1460 second.is_empty(),
1461 "second call must discover nothing (got {:?})",
1462 second
1463 );
1464 assert_eq!(
1465 len_after_first, len_after_second,
1466 "mcp map length must not change between calls (first discovered {:?})",
1467 first
1468 );
1469 }
1470
1471 #[test]
1472 fn test_auto_discover_mcp_preserves_existing_entries() {
1473 let mut config = PawanConfig::default();
1477 let custom = McpServerEntry {
1478 command: "custom-eruka".to_string(),
1479 args: vec!["--custom-flag".to_string()],
1480 env: HashMap::new(),
1481 enabled: true,
1482 };
1483 config.mcp.insert("eruka".to_string(), custom);
1484
1485 let discovered = config.auto_discover_mcp_servers();
1486
1487 assert!(
1489 !discovered.contains(&"eruka".to_string()),
1490 "pre-existing 'eruka' entry must not be rediscovered, got {:?}",
1491 discovered
1492 );
1493
1494 let entry = config.mcp.get("eruka").expect("eruka entry must still exist");
1496 assert_eq!(entry.command, "custom-eruka", "custom command must be preserved");
1497 assert_eq!(entry.args, vec!["--custom-flag".to_string()]);
1498 }
1499
1500 #[test]
1503 fn test_discover_skills_from_repo_returns_parsed_skills() {
1504 let repo = tempfile::TempDir::new().expect("tempdir");
1507
1508 let skill_dir = repo.path().join("example-skill");
1510 std::fs::create_dir(&skill_dir).expect("mkdir example-skill");
1511 let skill_md = skill_dir.join("SKILL.md");
1512 std::fs::write(
1513 &skill_md,
1514 "---\nname: example-skill\ndescription: A test skill used in pawan unit tests\n---\n# Instructions\n\nDo the thing.\n",
1515 )
1516 .expect("write SKILL.md");
1517
1518 let empty_dir = repo.path().join("not-a-skill");
1520 std::fs::create_dir(&empty_dir).expect("mkdir not-a-skill");
1521
1522 let config = PawanConfig {
1523 skills_repo: Some(repo.path().to_path_buf()),
1524 ..Default::default()
1525 };
1526
1527 std::env::remove_var("PAWAN_SKILLS_REPO");
1529
1530 let skills = config.discover_skills_from_repo();
1531 assert_eq!(skills.len(), 1, "expected exactly 1 skill, got {:?}", skills);
1532
1533 let (name, desc, path) = &skills[0];
1534 assert_eq!(name, "example-skill");
1535 assert_eq!(desc, "A test skill used in pawan unit tests");
1536 assert_eq!(path, &skill_md);
1537 }
1538
1539 #[test]
1542 fn test_load_with_explicit_pawan_toml_path() {
1543 let tmp = tempfile::TempDir::new().expect("tempdir");
1545 let path = tmp.path().join("pawan.toml");
1546 std::fs::write(
1547 &path,
1548 r#"
1549provider = "nvidia"
1550model = "meta/llama-3.1-405b-instruct"
1551"#,
1552 )
1553 .expect("write pawan.toml");
1554
1555 let config = PawanConfig::load(Some(&path)).expect("load should succeed");
1556 assert_eq!(config.model, "meta/llama-3.1-405b-instruct");
1557 }
1558
1559 #[test]
1560 fn test_load_with_invalid_toml_returns_error() {
1561 let tmp = tempfile::TempDir::new().expect("tempdir");
1563 let path = tmp.path().join("pawan.toml");
1564 std::fs::write(&path, "this is not [[valid] toml @@").expect("write bad toml");
1565
1566 let result = PawanConfig::load(Some(&path));
1567 assert!(result.is_err(), "malformed TOML must return Err");
1568 let err_msg = format!("{}", result.unwrap_err());
1569 assert!(
1570 err_msg.to_lowercase().contains("parse")
1571 || err_msg.to_lowercase().contains("failed"),
1572 "error should mention parse/failed, got: {}",
1573 err_msg
1574 );
1575 }
1576
1577 #[test]
1578 fn test_load_with_nonexistent_path_returns_error() {
1579 let bogus = PathBuf::from("/tmp/definitely-does-not-exist-abc123-xyz.toml");
1583 let result = PawanConfig::load(Some(&bogus));
1584 assert!(
1585 result.is_err(),
1586 "non-existent explicit path must return Err"
1587 );
1588 }
1589
1590 #[test]
1591 fn test_load_ares_toml_with_pawan_section() {
1592 let tmp = tempfile::TempDir::new().expect("tempdir");
1594 let path = tmp.path().join("ares.toml");
1595 std::fs::write(
1596 &path,
1597 r#"
1598# ares config (unrelated to pawan)
1599[server]
1600port = 3000
1601
1602[pawan]
1603provider = "ollama"
1604model = "qwen3-coder:30b"
1605"#,
1606 )
1607 .expect("write ares.toml");
1608
1609 let config = PawanConfig::load(Some(&path)).expect("ares.toml load should succeed");
1610 assert_eq!(config.provider, LlmProvider::Ollama);
1611 assert_eq!(config.model, "qwen3-coder:30b");
1612 }
1613
1614 #[test]
1615 fn test_load_ares_toml_without_pawan_section_returns_defaults() {
1616 let tmp = tempfile::TempDir::new().expect("tempdir");
1620 let path = tmp.path().join("ares.toml");
1621 std::fs::write(
1622 &path,
1623 r#"
1624[server]
1625port = 3000
1626workers = 4
1627"#,
1628 )
1629 .expect("write ares.toml without pawan section");
1630
1631 let config = PawanConfig::load(Some(&path)).expect("load should succeed");
1632 let defaults = PawanConfig::default();
1634 assert_eq!(config.provider, defaults.provider);
1635 assert_eq!(config.model, defaults.model);
1636 }
1637
1638 #[test]
1639 fn test_load_empty_toml_file_returns_defaults() {
1640 let tmp = tempfile::TempDir::new().expect("tempdir");
1643 let path = tmp.path().join("pawan.toml");
1644 std::fs::write(&path, "").expect("write empty toml");
1645
1646 let config = PawanConfig::load(Some(&path)).expect("empty toml should load");
1647 let defaults = PawanConfig::default();
1648 assert_eq!(config.provider, defaults.provider);
1649 }
1650}
1651 #[test]
1652 fn test_default_config_version() {
1653 assert_eq!(default_config_version(), 1);
1654 }
1655
1656 #[test]
1657 fn test_default_tool_idle_timeout() {
1658 assert_eq!(default_tool_idle_timeout(), 300);
1659 }
1660
1661 #[test]
1662 fn test_config_version_field_exists() {
1663 let config = PawanConfig::default();
1664 assert_eq!(config.config_version, 1);
1665 }
1666
1667 #[test]
1668 fn test_tool_idle_timeout_field_exists() {
1669 let config = PawanConfig::default();
1670 assert_eq!(config.tool_call_idle_timeout_secs, 300);
1671 }
1672
1673 #[test]
1674 fn test_migration_result_fields() {
1675 let result = MigrationResult {
1676 migrated: true,
1677 from_version: 0,
1678 to_version: 1,
1679 backup_path: Some(std::path::PathBuf::from("/tmp/backup.toml")),
1680 };
1681 assert!(result.migrated);
1682 assert_eq!(result.from_version, 0);
1683 assert_eq!(result.to_version, 1);
1684 assert!(result.backup_path.is_some());
1685 }
1686
1687 #[test]
1688 fn test_migrate_to_latest_no_migration_needed() {
1689 let mut config = PawanConfig::default();
1690 config.config_version = 1; let result = migrate_to_latest(&mut config, None);
1693
1694 assert!(!result.migrated, "Should not migrate if already at latest version");
1695 assert_eq!(result.from_version, 1);
1696 assert_eq!(result.to_version, 1);
1697 }
1698
1699 #[test]
1700 fn test_migrate_to_latest_performs_migration() {
1701 let mut config = PawanConfig::default();
1702 config.config_version = 0; let result = migrate_to_latest(&mut config, None);
1705
1706 assert!(result.migrated, "Should migrate from old version");
1707 assert_eq!(result.from_version, 0);
1708 assert_eq!(result.to_version, 1);
1709 assert_eq!(config.config_version, 1, "Config version should be updated");
1710 }
1711
1712 #[test]
1713 fn test_migrate_to_v1_adds_default_fields() {
1714 let mut config = PawanConfig::default();
1715 config.config_version = 0;
1716
1717 let result = migrate_to_v1(&mut config);
1718
1719 assert!(result.is_ok(), "Migration should succeed");
1720 assert_eq!(result.unwrap(), 1, "Should return new version");
1721 assert_eq!(config.config_version, 1, "Config version should be updated");
1722 }
1723
1724 #[test]
1725 fn test_migration_result_no_migration() {
1726 let result = MigrationResult::no_migration(1);
1727
1728 assert!(!result.migrated, "Should indicate no migration");
1729 assert_eq!(result.from_version, 1);
1730 assert_eq!(result.to_version, 1);
1731 assert!(result.backup_path.is_none(), "Should not have backup path");
1732 }
1733
1734 #[test]
1735 fn test_migration_result_with_backup() {
1736 let backup_path = std::path::PathBuf::from("/tmp/backup.toml");
1737 let result = MigrationResult::new(0, 1, Some(backup_path.clone()));
1738
1739 assert!(result.migrated, "Should indicate migration occurred");
1740 assert_eq!(result.from_version, 0);
1741 assert_eq!(result.to_version, 1);
1742 assert_eq!(result.backup_path, Some(backup_path), "Should have backup path");
1743 }
1744