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
24pub mod migration;
25pub use migration::{migrate_to_latest, save_config, MigrationResult};
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
29#[serde(rename_all = "lowercase")]
30pub enum LlmProvider {
31 #[default]
33 Nvidia,
34 Ollama,
36 OpenAI,
38 Mlx,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44#[serde(default)]
45pub struct PawanConfig {
46 #[serde(default = "default_config_version")]
48 pub config_version: u32,
49
50 pub provider: LlmProvider,
52
53 pub model: String,
55
56 pub base_url: Option<String>,
59
60 pub dry_run: bool,
62
63 pub auto_backup: bool,
65
66 pub require_git_clean: bool,
68
69 pub bash_timeout_secs: u64,
71
72 #[serde(default = "default_tool_idle_timeout")]
75 pub tool_call_idle_timeout_secs: u64,
76
77 pub max_file_size_kb: usize,
79
80 pub max_tool_iterations: usize,
82 pub max_context_tokens: usize,
84
85 pub system_prompt: Option<String>,
87
88 pub temperature: f32,
90
91 pub top_p: f32,
93
94 pub max_tokens: usize,
96
97 pub thinking_budget: usize,
101
102 pub max_retries: usize,
104
105 pub fallback_models: Vec<String>,
107 pub max_result_chars: usize,
109
110 pub reasoning_mode: bool,
112
113 pub healing: HealingConfig,
115
116 pub targets: HashMap<String, TargetConfig>,
118
119 pub tui: TuiConfig,
121
122 #[serde(default)]
124 pub mcp: HashMap<String, McpServerEntry>,
125
126 #[serde(default)]
128 pub permissions: HashMap<String, ToolPermission>,
129
130 pub cloud: Option<CloudConfig>,
133
134 #[serde(default)]
137 pub models: ModelRouting,
138
139 #[serde(default)]
141 pub eruka: crate::eruka_bridge::ErukaConfig,
142
143 #[serde(default)]
149 pub use_ares_backend: bool,
150 #[serde(default)]
155 pub use_coordinator: bool,
156
157 #[serde(default)]
170 pub skills_repo: Option<PathBuf>,
171
172 #[serde(default)]
179 pub local_first: bool,
180
181 #[serde(default)]
185 pub local_endpoint: Option<String>,
186}
187
188#[derive(Debug, Clone, Default, Serialize, Deserialize)]
198pub struct ModelRouting {
199 pub code: Option<String>,
201 pub orchestrate: Option<String>,
203 pub execute: Option<String>,
205}
206
207impl ModelRouting {
208 pub fn route(&self, query: &str) -> Option<&str> {
211 let q = query.to_lowercase();
212
213 if self.code.is_some() {
215 let code_signals = [
216 "implement",
217 "write",
218 "create",
219 "refactor",
220 "fix",
221 "add test",
222 "add function",
223 "struct",
224 "enum",
225 "trait",
226 "algorithm",
227 "data structure",
228 ];
229 if code_signals.iter().any(|s| q.contains(s)) {
230 return self.code.as_deref();
231 }
232 }
233
234 if self.orchestrate.is_some() {
236 let orch_signals = [
237 "search", "find", "analyze", "review", "explain", "compare", "list", "check",
238 "verify", "diagnose", "audit",
239 ];
240 if orch_signals.iter().any(|s| q.contains(s)) {
241 return self.orchestrate.as_deref();
242 }
243 }
244
245 if self.execute.is_some() {
247 let exec_signals = [
248 "run", "execute", "bash", "cargo", "test", "build", "deploy", "install", "commit",
249 ];
250 if exec_signals.iter().any(|s| q.contains(s)) {
251 return self.execute.as_deref();
252 }
253 }
254
255 None
256 }
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct CloudConfig {
276 pub provider: LlmProvider,
278 pub model: String,
280 #[serde(default)]
282 pub fallback_models: Vec<String>,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
287#[serde(rename_all = "lowercase")]
288pub enum ToolPermission {
289 Allow,
291 Deny,
293 Prompt,
295}
296
297impl ToolPermission {
298 pub fn resolve(name: &str, permissions: &HashMap<String, ToolPermission>) -> Self {
303 if let Some(p) = permissions.get(name) {
304 return p.clone();
305 }
306 match name {
308 "bash" | "git_commit" | "write_file" | "edit_file_lines" | "insert_after"
309 | "append_file" => ToolPermission::Allow, _ => ToolPermission::Allow,
311 }
312 }
313}
314
315impl Default for PawanConfig {
316 fn default() -> Self {
317 let mut targets = HashMap::new();
318 targets.insert(
319 "self".to_string(),
320 TargetConfig {
321 path: PathBuf::from("."),
322 description: "Current project codebase".to_string(),
323 },
324 );
325
326 Self {
327 provider: LlmProvider::Nvidia,
328 config_version: default_config_version(),
329 model: crate::DEFAULT_MODEL.to_string(),
330 base_url: None,
331 dry_run: false,
332 auto_backup: true,
333 require_git_clean: false,
334 bash_timeout_secs: crate::DEFAULT_BASH_TIMEOUT,
335 tool_call_idle_timeout_secs: default_tool_idle_timeout(),
336 max_file_size_kb: 1024,
337 max_tool_iterations: crate::MAX_TOOL_ITERATIONS,
338 max_context_tokens: 100000,
339 system_prompt: None,
340 temperature: 1.0,
341 top_p: 0.95,
342 max_tokens: 8192,
343 thinking_budget: 0, reasoning_mode: true,
345 max_retries: 3,
346 fallback_models: Vec::new(),
347 max_result_chars: 8000,
348 healing: HealingConfig::default(),
349 targets,
350 tui: TuiConfig::default(),
351 mcp: HashMap::new(),
352 permissions: HashMap::new(),
353 cloud: None,
354 models: ModelRouting::default(),
355 eruka: crate::eruka_bridge::ErukaConfig::default(),
356 use_ares_backend: false,
357 use_coordinator: false,
358 skills_repo: None,
359 local_first: false,
360 local_endpoint: None,
361 }
362 }
363}
364
365#[derive(Debug, Clone, Serialize, Deserialize)]
367#[serde(default)]
368pub struct HealingConfig {
369 pub auto_commit: bool,
371
372 pub fix_errors: bool,
374
375 pub fix_warnings: bool,
377
378 pub fix_tests: bool,
380
381 pub generate_docs: bool,
383
384 #[serde(default)]
388 pub fix_security: bool,
389
390 pub max_attempts: usize,
392
393 #[serde(default)]
400 pub verify_cmd: Option<String>,
401}
402
403impl Default for HealingConfig {
404 fn default() -> Self {
405 Self {
406 auto_commit: false,
407 fix_errors: true,
408 fix_warnings: true,
409 fix_tests: true,
410 generate_docs: false,
411 fix_security: false,
412 max_attempts: 3,
413 verify_cmd: None,
414 }
415 }
416}
417
418#[derive(Debug, Clone, Serialize, Deserialize)]
420pub struct TargetConfig {
425 pub path: PathBuf,
427
428 pub description: String,
430}
431
432#[derive(Debug, Clone, Serialize, Deserialize)]
434#[serde(default)]
435pub struct TuiConfig {
436 pub syntax_highlighting: bool,
438
439 pub theme: String,
441
442 pub line_numbers: bool,
444
445 pub mouse_support: bool,
447
448 pub scroll_speed: usize,
450
451 pub max_history: usize,
453
454 pub auto_save_enabled: bool,
456 pub auto_save_interval_minutes: u32,
458 pub auto_save_dir: Option<std::path::PathBuf>,
460}
461
462impl Default for TuiConfig {
463 fn default() -> Self {
464 Self {
465 syntax_highlighting: true,
466 theme: "base16-ocean.dark".to_string(),
467 line_numbers: true,
468 mouse_support: true,
469 scroll_speed: 3,
470 max_history: 1000,
471 auto_save_enabled: true,
472 auto_save_interval_minutes: 5,
473 auto_save_dir: None,
474 }
475 }
476}
477
478#[derive(Debug, Clone, Serialize, Deserialize)]
480pub struct McpServerEntry {
486 pub command: String,
488 #[serde(default)]
490 pub args: Vec<String>,
491 #[serde(default)]
493 pub env: HashMap<String, String>,
494 #[serde(default = "default_true")]
496 pub enabled: bool,
497}
498
499fn default_true() -> bool {
500 true
501}
502
503impl PawanConfig {
504 pub fn load(path: Option<&PathBuf>) -> crate::Result<Self> {
506 let config_path = path.cloned().or_else(|| {
507 let pawan_toml = PathBuf::from("pawan.toml");
509 if pawan_toml.exists() {
510 return Some(pawan_toml);
511 }
512
513 let ares_toml = PathBuf::from("ares.toml");
515 if ares_toml.exists() {
516 return Some(ares_toml);
517 }
518
519 if let Some(home) = dirs::home_dir() {
521 let global = home.join(".config/pawan/pawan.toml");
522 if global.exists() {
523 return Some(global);
524 }
525 }
526
527 None
528 });
529
530 match config_path {
531 Some(path) => {
532 let content = std::fs::read_to_string(&path).map_err(|e| {
533 crate::PawanError::Config(format!("Failed to read {}: {}", path.display(), e))
534 })?;
535
536 if path.file_name().map(|n| n == "ares.toml").unwrap_or(false) {
538 let value: toml::Value = toml::from_str(&content).map_err(|e| {
540 crate::PawanError::Config(format!(
541 "Failed to parse {}: {}",
542 path.display(),
543 e
544 ))
545 })?;
546
547 if let Some(pawan_section) = value.get("pawan") {
548 let config: PawanConfig =
549 pawan_section.clone().try_into().map_err(|e| {
550 crate::PawanError::Config(format!(
551 "Failed to parse [pawan] section: {}",
552 e
553 ))
554 })?;
555 return Ok(config);
556 }
557
558 Ok(Self::default())
560 } else {
561 let mut config: PawanConfig = toml::from_str(&content).map_err(|e| {
563 crate::PawanError::Config(format!(
564 "Failed to parse {}: {}",
565 path.display(),
566 e
567 ))
568 })?;
569
570 let migration_result = migrate_to_latest(&mut config, Some(&path));
572 if migration_result.migrated {
573 tracing::info!(
574 from_version = migration_result.from_version,
575 to_version = migration_result.to_version,
576 backup = ?migration_result.backup_path,
577 "Config migrated"
578 );
579
580 if let Err(e) = save_config(&config, &path) {
582 tracing::warn!(error = %e, "Failed to save migrated config");
583 }
584 }
585
586 Ok(config)
587 }
588 }
589 None => Ok(Self::default()),
590 }
591 }
592
593 pub fn apply_env_overrides(&mut self) {
595 if let Ok(model) = std::env::var("PAWAN_MODEL") {
596 self.model = model;
597 }
598 if let Ok(provider) = std::env::var("PAWAN_PROVIDER") {
599 match provider.to_lowercase().as_str() {
600 "nvidia" | "nim" => self.provider = LlmProvider::Nvidia,
601 "ollama" => self.provider = LlmProvider::Ollama,
602 "openai" => self.provider = LlmProvider::OpenAI,
603 "mlx" | "mlx-lm" => self.provider = LlmProvider::Mlx,
604 _ => tracing::warn!(
605 provider = provider.as_str(),
606 "Unknown PAWAN_PROVIDER, ignoring"
607 ),
608 }
609 }
610 if let Ok(temp) = std::env::var("PAWAN_TEMPERATURE") {
611 if let Ok(t) = temp.parse::<f32>() {
612 self.temperature = t;
613 }
614 }
615 if let Ok(tokens) = std::env::var("PAWAN_MAX_TOKENS") {
616 if let Ok(t) = tokens.parse::<usize>() {
617 self.max_tokens = t;
618 }
619 }
620 if let Ok(iters) = std::env::var("PAWAN_MAX_ITERATIONS") {
621 if let Ok(i) = iters.parse::<usize>() {
622 self.max_tool_iterations = i;
623 }
624 }
625 if let Ok(ctx) = std::env::var("PAWAN_MAX_CONTEXT_TOKENS") {
626 if let Ok(c) = ctx.parse::<usize>() {
627 self.max_context_tokens = c;
628 }
629 }
630 if let Ok(models) = std::env::var("PAWAN_FALLBACK_MODELS") {
631 self.fallback_models = models
632 .split(',')
633 .map(|s| s.trim().to_string())
634 .filter(|s| !s.is_empty())
635 .collect();
636 }
637 if let Ok(chars) = std::env::var("PAWAN_MAX_RESULT_CHARS") {
638 if let Ok(c) = chars.parse::<usize>() {
639 self.max_result_chars = c;
640 }
641 }
642 }
643
644 pub fn get_target(&self, name: &str) -> Option<&TargetConfig> {
646 self.targets.get(name)
647 }
648
649 pub fn get_system_prompt(&self) -> String {
652 match self.get_system_prompt_checked() {
653 Ok(p) => p,
654 Err(e) => {
655 tracing::error!("Failed to load project context for system prompt: {}", e);
656 self.system_prompt
657 .clone()
658 .unwrap_or_else(|| DEFAULT_SYSTEM_PROMPT.to_string())
659 }
660 }
661 }
662
663 pub fn get_system_prompt_checked(&self) -> crate::Result<String> {
666 let base = self
667 .system_prompt
668 .clone()
669 .unwrap_or_else(|| DEFAULT_SYSTEM_PROMPT.to_string());
670
671 let mut prompt = base;
672
673 if let Some((filename, ctx)) = Self::load_context_file()? {
674 prompt = format!(
675 "{}
676
677## Project Context (from {})
678
679{}",
680 prompt, filename, ctx
681 );
682 }
683
684 if let Some(skill_ctx) = Self::load_skill_context() {
685 prompt = format!(
686 "{}
687
688## Active Skill (from SKILL.md)
689
690{}",
691 prompt, skill_ctx
692 );
693 }
694
695 #[cfg(feature = "memory")]
696 {
697 if let Ok(store) = crate::memory::MemoryStore::new_default() {
698 prompt = crate::memory::inject_memory_guidance_into_prompt(prompt, &store);
699 }
700 }
701
702 Ok(prompt)
703 }
704
705 fn scan_context_file(content: &str, source: &str) -> crate::Result<String> {
706 let suspicious = [
708 "IGNORE ALL PREVIOUS",
709 "DISREGARD ALL",
710 "OVERRIDE",
711 "You are now",
712 "Your new role",
713 "IMPORTANT: do not",
714 "<system-directive>",
715 "<role>",
716 "<contract>",
717 "\u{200B}",
719 "\u{200C}",
720 "\u{200D}",
721 "\u{FEFF}",
722 "\u{202E}",
723 "\u{2060}",
724 "\u{2061}",
725 "\u{2062}",
726 ];
727
728 let upper = content.to_uppercase();
729 let allow = source == "AGENTS.md" || source == "CLAUDE.md";
730
731 for pattern in &suspicious {
732 let hit = if pattern.is_ascii() {
733 upper.contains(&pattern.to_uppercase())
734 } else {
735 content.contains(pattern)
736 };
737
738 if hit {
739 tracing::warn!(source = %source, pattern = %pattern, "prompt injection pattern detected");
740 if allow {
741 continue;
742 }
743 return Err(crate::PawanError::Config(format!(
744 "Suspicious content in {}: contains '{}'",
745 source, pattern
746 )));
747 }
748 }
749
750 Ok(content.to_string())
751 }
752
753 fn load_context_file() -> crate::Result<Option<(String, String)>> {
757 for path in &["PAWAN.md", "AGENTS.md", "CLAUDE.md", ".pawan/context.md"] {
758 let p = PathBuf::from(path);
759 if p.exists() {
760 let bytes = std::fs::read(&p).map_err(crate::PawanError::Io)?;
761 let content = String::from_utf8(bytes).map_err(|_| {
762 crate::PawanError::Config(format!(
763 "Suspicious content in {}: file is not valid UTF-8 (binary?)",
764 path
765 ))
766 })?;
767
768 let content = Self::scan_context_file(&content, path)?;
769 if !content.trim().is_empty() {
770 return Ok(Some((path.to_string(), content)));
771 }
772 }
773 }
774 Ok(None)
775 }
776
777 fn load_skill_context() -> Option<String> {
781 use thulp_skill_files::SkillFile;
782
783 let skill_path = std::path::Path::new("SKILL.md");
784 if !skill_path.exists() {
785 return None;
786 }
787
788 match SkillFile::parse(skill_path) {
789 Ok(skill) => {
790 let name = skill.effective_name();
791 let desc = skill
792 .frontmatter
793 .description
794 .as_deref()
795 .unwrap_or("no description");
796 let tools_str = match &skill.frontmatter.allowed_tools {
797 Some(tools) => tools.join(", "),
798 None => "all".to_string(),
799 };
800 Some(format!(
801 "[Skill: {}] {}\nAllowed tools: {}\n---\n{}",
802 name, desc, tools_str, skill.content
803 ))
804 }
805 Err(e) => {
806 tracing::warn!("Failed to parse SKILL.md: {}", e);
807 None
808 }
809 }
810 }
811
812 pub fn resolve_skills_repo(&self) -> Option<PathBuf> {
819 if let Ok(env_path) = std::env::var("PAWAN_SKILLS_REPO") {
821 let p = PathBuf::from(env_path);
822 if p.is_dir() {
823 return Some(p);
824 }
825 tracing::warn!(path = %p.display(), "PAWAN_SKILLS_REPO set but directory does not exist");
826 }
827
828 if let Some(ref p) = self.skills_repo {
830 if p.is_dir() {
831 return Some(p.clone());
832 }
833 tracing::warn!(path = %p.display(), "config.skills_repo set but directory does not exist");
834 }
835
836 if let Some(home) = dirs::home_dir() {
838 let default = home.join(".config").join("pawan").join("skills");
839 if default.is_dir() {
840 return Some(default);
841 }
842 }
843
844 None
845 }
846
847 pub fn auto_discover_mcp_servers(&mut self) -> Vec<String> {
858 let mut discovered = Vec::new();
859
860 if !self.mcp.contains_key("eruka") && which::which("eruka-mcp").is_ok() {
862 self.mcp.insert(
863 "eruka".to_string(),
864 McpServerEntry {
865 command: "eruka-mcp".to_string(),
866 args: vec!["--transport".to_string(), "stdio".to_string()],
867 env: HashMap::new(),
868 enabled: true,
869 },
870 );
871 discovered.push("eruka".to_string());
872 tracing::info!("auto-discovered eruka-mcp");
873 }
874
875 if !self.mcp.contains_key("daedra") && which::which("daedra").is_ok() {
877 self.mcp.insert(
878 "daedra".to_string(),
879 McpServerEntry {
880 command: "daedra".to_string(),
881 args: vec![
882 "serve".to_string(),
883 "--transport".to_string(),
884 "stdio".to_string(),
885 "--quiet".to_string(),
886 ],
887 env: HashMap::new(),
888 enabled: true,
889 },
890 );
891 discovered.push("daedra".to_string());
892 tracing::info!("auto-discovered daedra");
893 }
894
895 if !self.mcp.contains_key("deagle") && which::which("deagle-mcp").is_ok() {
897 self.mcp.insert(
898 "deagle".to_string(),
899 McpServerEntry {
900 command: "deagle-mcp".to_string(),
901 args: vec!["--transport".to_string(), "stdio".to_string()],
902 env: HashMap::new(),
903 enabled: true,
904 },
905 );
906 discovered.push("deagle".to_string());
907 tracing::info!("auto-discovered deagle-mcp");
908 }
909
910 discovered
911 }
912
913 pub fn discover_skills_from_repo(&self) -> Vec<(String, String, PathBuf)> {
924 use thulp_skill_files::SkillFile;
925
926 let repo = match self.resolve_skills_repo() {
927 Some(r) => r,
928 None => return Vec::new(),
929 };
930
931 let mut results = Vec::new();
932 let walker = match std::fs::read_dir(&repo) {
933 Ok(w) => w,
934 Err(e) => {
935 tracing::warn!(path = %repo.display(), error = %e, "failed to read skills repo");
936 return Vec::new();
937 }
938 };
939
940 for entry in walker.flatten() {
941 let path = entry.path();
942 let skill_file = path.join("SKILL.md");
944 if !skill_file.is_file() {
945 continue;
946 }
947 match SkillFile::parse(&skill_file) {
948 Ok(skill) => {
949 let name = skill.effective_name();
950 let desc = skill
951 .frontmatter
952 .description
953 .clone()
954 .unwrap_or_else(|| "(no description)".to_string());
955 results.push((name, desc, skill_file));
956 }
957 Err(e) => {
958 tracing::debug!(path = %skill_file.display(), error = %e, "skip unparseable skill");
959 }
960 }
961 }
962
963 results.sort_by(|a, b| a.0.cmp(&b.0));
964 results
965 }
966
967 pub fn use_thinking_mode(&self) -> bool {
970 self.reasoning_mode
971 && (self.model.contains("deepseek")
972 || self.model.contains("gemma")
973 || self.model.contains("glm")
974 || self.model.contains("qwen")
975 || self.model.contains("mistral-small-4"))
976 }
977}
978
979pub const DEFAULT_SYSTEM_PROMPT: &str = r#"You are Pawan, an expert coding assistant.
981
982# Efficiency
983- Act immediately. Do NOT explore or plan before writing. Write code FIRST, then verify.
984- write_file creates parents automatically. No mkdir needed.
985- cargo check runs automatically after .rs writes — fix errors immediately.
986- Use relative paths from workspace root.
987- Missing tools are auto-installed via mise. Don't check dependencies.
988- You have limited tool iterations. Be direct. No preamble.
989
990# Tool Selection
991Use the BEST tool for the job — do NOT use bash for things dedicated tools handle:
992- File ops: read_file, write_file, edit_file, edit_file_lines, insert_after, append_file, list_directory
993- Code intelligence: ast_grep (AST search + rewrite via tree-sitter — prefer for structural changes)
994- Search: glob_search (files by pattern), grep_search (content by regex), ripgrep (native rg), fd (native find)
995- Shell: bash (commands), sd (find-replace in files), mise (tool/task/env manager), zoxide (smart cd)
996- Git: git_status, git_diff, git_add, git_commit, git_log, git_blame, git_branch, git_checkout, git_stash
997- Agent: spawn_agent (delegate subtask), spawn_agents (parallel sub-agents)
998- Web: mcp_daedra_web_search (ALWAYS use for web queries — never bash+curl)
999
1000Prefer ast_grep over edit_file for code refactors. Prefer grep_search over bash grep.
1001Prefer fd over bash find. Prefer sd over bash sed.
1002
1003# Parallel Execution
1004Call multiple tools in a single response when they are independent.
1005If tool B depends on tool A's result, call them sequentially.
1006Never parallelize destructive operations (writes, deletes, commits).
1007
1008# Read Before Modifying
1009Do NOT propose changes to code you haven't read. If asked to modify a file, read it first.
1010Understand existing code, patterns, and style before suggesting changes.
1011
1012# Scope Discipline
1013Make minimal, focused changes. Follow existing code style.
1014- Don't add features, refactor, or "improve" code beyond what was asked.
1015- Don't add docstrings, comments, or type annotations to code you didn't change.
1016- A bug fix doesn't need surrounding code cleaned up.
1017- Don't add error handling for scenarios that can't happen.
1018
1019# Executing Actions with Care
1020Consider reversibility and blast radius before acting:
1021- Freely take local, reversible actions (editing files, running tests).
1022- For hard-to-reverse actions (force-push, rm -rf, dropping tables), ask first.
1023- Match the scope of your actions to what was requested.
1024- Investigate before deleting — unfamiliar files may be the user's in-progress work.
1025- Don't use destructive shortcuts to bypass safety checks.
1026
1027# Git Safety
1028- NEVER skip hooks (--no-verify) unless explicitly asked.
1029- ALWAYS create NEW commits rather than amending (amend after hook failure destroys work).
1030- NEVER force-push to main/master. Warn if requested.
1031- Prefer staging specific files over `git add -A` (avoids committing secrets).
1032- Only commit when explicitly asked. Don't be over-eager.
1033- Commit messages: focus on WHY, not WHAT. Use HEREDOC for multi-line messages.
1034- Use the git author from `git config user.name` / `git config user.email`.
1035
1036# Output Style
1037Be concise. Lead with the answer, not the reasoning.
1038Focus text output on: decisions needing input, status updates, errors/blockers.
1039If you can say it in one sentence, don't use three.
1040After .rs writes, cargo check auto-runs — fix errors immediately if it fails.
1041Run tests when the task calls for it (cargo test -p <crate>).
1042One fix at a time. If it doesn't work, try a different approach."#;
1043
1044#[cfg(test)]
1045mod tests {
1046 use super::*;
1047
1048 #[test]
1049 fn test_provider_mlx_parsing() {
1050 let toml = r#"
1052provider = "mlx"
1053model = "mlx-community/Qwen3.5-9B-4bit"
1054"#;
1055 let config: PawanConfig = toml::from_str(toml).expect("should parse without error");
1056 assert_eq!(config.provider, LlmProvider::Mlx);
1057 assert_eq!(config.model, "mlx-community/Qwen3.5-9B-4bit");
1058 }
1059
1060 #[test]
1061 fn test_provider_mlx_lm_alias() {
1062 let mut config = PawanConfig::default();
1064 std::env::set_var("PAWAN_PROVIDER", "mlx-lm");
1065 config.apply_env_overrides();
1066 std::env::remove_var("PAWAN_PROVIDER");
1067 assert_eq!(config.provider, LlmProvider::Mlx);
1068 }
1069
1070 #[test]
1071 fn test_mlx_base_url_override() {
1072 let toml = r#"
1074provider = "mlx"
1075model = "test-model"
1076base_url = "http://192.168.1.100:8080/v1"
1077"#;
1078 let config: PawanConfig = toml::from_str(toml).expect("should parse without error");
1079 assert_eq!(config.provider, LlmProvider::Mlx);
1080 assert_eq!(
1081 config.base_url.as_deref(),
1082 Some("http://192.168.1.100:8080/v1")
1083 );
1084 }
1085
1086 #[test]
1089 fn test_route_code_signals() {
1090 let routing = ModelRouting {
1091 code: Some("code-model".into()),
1092 orchestrate: Some("orch-model".into()),
1093 execute: Some("exec-model".into()),
1094 };
1095 assert_eq!(routing.route("implement a linked list"), Some("code-model"));
1096 assert_eq!(routing.route("refactor the parser"), Some("code-model"));
1097 assert_eq!(routing.route("add test for config"), Some("code-model"));
1098 assert_eq!(routing.route("Write a new struct"), Some("code-model"));
1099 }
1100
1101 #[test]
1102 fn test_route_orchestration_signals() {
1103 let routing = ModelRouting {
1104 code: Some("code-model".into()),
1105 orchestrate: Some("orch-model".into()),
1106 execute: Some("exec-model".into()),
1107 };
1108 assert_eq!(routing.route("analyze the error logs"), Some("orch-model"));
1109 assert_eq!(routing.route("review this PR"), Some("orch-model"));
1110 assert_eq!(
1111 routing.route("explain how the agent works"),
1112 Some("orch-model")
1113 );
1114 assert_eq!(routing.route("search for uses of foo"), Some("orch-model"));
1115 }
1116
1117 #[test]
1118 fn test_route_execution_signals() {
1119 let routing = ModelRouting {
1120 code: Some("code-model".into()),
1121 orchestrate: Some("orch-model".into()),
1122 execute: Some("exec-model".into()),
1123 };
1124 assert_eq!(routing.route("run cargo test"), Some("exec-model"));
1125 assert_eq!(
1126 routing.route("execute the deploy script"),
1127 Some("exec-model")
1128 );
1129 assert_eq!(routing.route("build the project"), Some("exec-model"));
1130 assert_eq!(routing.route("commit these changes"), Some("exec-model"));
1131 }
1132
1133 #[test]
1134 fn test_route_no_match_returns_none() {
1135 let routing = ModelRouting {
1136 code: Some("code-model".into()),
1137 orchestrate: Some("orch-model".into()),
1138 execute: Some("exec-model".into()),
1139 };
1140 assert_eq!(routing.route("hello world"), None);
1141 }
1142
1143 #[test]
1144 fn test_route_empty_routing_returns_none() {
1145 let routing = ModelRouting::default();
1146 assert_eq!(routing.route("implement something"), None);
1147 assert_eq!(routing.route("search for bugs"), None);
1148 }
1149
1150 #[test]
1151 fn test_route_case_insensitive() {
1152 let routing = ModelRouting {
1153 code: Some("code-model".into()),
1154 orchestrate: None,
1155 execute: None,
1156 };
1157 assert_eq!(routing.route("IMPLEMENT a FUNCTION"), Some("code-model"));
1158 }
1159
1160 #[test]
1161 fn test_route_partial_routing() {
1162 let routing = ModelRouting {
1164 code: Some("code-model".into()),
1165 orchestrate: None,
1166 execute: None,
1167 };
1168 assert_eq!(routing.route("implement x"), Some("code-model"));
1169 assert_eq!(routing.route("search for y"), None);
1170 assert_eq!(routing.route("run tests"), None);
1171 }
1172
1173 #[test]
1176 fn test_env_override_model() {
1177 let mut config = PawanConfig::default();
1178 std::env::set_var("PAWAN_MODEL", "custom/model-123");
1179 config.apply_env_overrides();
1180 std::env::remove_var("PAWAN_MODEL");
1181 assert_eq!(config.model, "custom/model-123");
1182 }
1183
1184 #[test]
1185 fn test_env_override_temperature() {
1186 let mut config = PawanConfig::default();
1187 std::env::set_var("PAWAN_TEMPERATURE", "0.9");
1188 config.apply_env_overrides();
1189 std::env::remove_var("PAWAN_TEMPERATURE");
1190 assert!((config.temperature - 0.9).abs() < f32::EPSILON);
1191 }
1192
1193 #[test]
1194 fn test_env_override_invalid_temperature_ignored() {
1195 let mut config = PawanConfig::default();
1196 let original = config.temperature;
1197 std::env::set_var("PAWAN_TEMPERATURE", "not_a_number");
1198 config.apply_env_overrides();
1199 std::env::remove_var("PAWAN_TEMPERATURE");
1200 assert!((config.temperature - original).abs() < f32::EPSILON);
1201 }
1202
1203 #[test]
1204 fn test_env_override_max_tokens() {
1205 let mut config = PawanConfig::default();
1206 std::env::set_var("PAWAN_MAX_TOKENS", "16384");
1207 config.apply_env_overrides();
1208 std::env::remove_var("PAWAN_MAX_TOKENS");
1209 assert_eq!(config.max_tokens, 16384);
1210 }
1211
1212 #[test]
1213 fn test_env_override_fallback_models() {
1214 std::env::remove_var("PAWAN_FALLBACK_MODELS"); let mut config = PawanConfig::default();
1216 std::env::set_var("PAWAN_FALLBACK_MODELS", "model-a, model-b, model-c");
1217 config.apply_env_overrides();
1218 std::env::remove_var("PAWAN_FALLBACK_MODELS");
1219 assert_eq!(
1220 config.fallback_models,
1221 vec!["model-a", "model-b", "model-c"]
1222 );
1223 }
1224
1225 #[test]
1226 fn test_env_override_fallback_models_filters_empty() {
1227 std::env::remove_var("PAWAN_FALLBACK_MODELS"); let mut config = PawanConfig::default();
1229 std::env::set_var("PAWAN_FALLBACK_MODELS", "model-a,,, model-b,");
1230 config.apply_env_overrides();
1231 std::env::remove_var("PAWAN_FALLBACK_MODELS");
1232 assert_eq!(config.fallback_models, vec!["model-a", "model-b"]);
1233 }
1234
1235 #[test]
1236 fn test_env_override_provider_variants() {
1237 for (env_val, expected) in [
1238 ("nvidia", LlmProvider::Nvidia),
1239 ("nim", LlmProvider::Nvidia),
1240 ("ollama", LlmProvider::Ollama),
1241 ("openai", LlmProvider::OpenAI),
1242 ("mlx", LlmProvider::Mlx),
1243 ] {
1244 let mut config = PawanConfig::default();
1245 std::env::set_var("PAWAN_PROVIDER", env_val);
1246 config.apply_env_overrides();
1247 std::env::remove_var("PAWAN_PROVIDER");
1248 assert_eq!(
1249 config.provider, expected,
1250 "PAWAN_PROVIDER={} should map to {:?}",
1251 env_val, expected
1252 );
1253 }
1254 }
1255
1256 #[test]
1259 fn test_thinking_mode_supported_models() {
1260 for model in [
1261 "deepseek-ai/deepseek-r1",
1262 "google/gemma-4-31b-it",
1263 "z-ai/glm5",
1264 "qwen/qwen3.5-122b",
1265 "mistralai/mistral-small-4-119b",
1266 ] {
1267 let config = PawanConfig {
1268 model: model.into(),
1269 reasoning_mode: true,
1270 ..Default::default()
1271 };
1272 assert!(
1273 config.use_thinking_mode(),
1274 "thinking mode should be on for {}",
1275 model
1276 );
1277 }
1278 }
1279
1280 #[test]
1281 fn test_thinking_mode_disabled_when_reasoning_off() {
1282 let config = PawanConfig {
1283 model: "deepseek-ai/deepseek-r1".into(),
1284 reasoning_mode: false,
1285 ..Default::default()
1286 };
1287 assert!(!config.use_thinking_mode());
1288 }
1289
1290 #[test]
1291 fn test_thinking_mode_unsupported_models() {
1292 for model in [
1293 "meta/llama-3.1-70b",
1294 "minimaxai/minimax-m2.5",
1295 "stepfun-ai/step-3.5-flash",
1296 ] {
1297 let config = PawanConfig {
1298 model: model.into(),
1299 reasoning_mode: true,
1300 ..Default::default()
1301 };
1302 assert!(
1303 !config.use_thinking_mode(),
1304 "thinking mode should be off for {}",
1305 model
1306 );
1307 }
1308 }
1309
1310 #[test]
1313 fn test_system_prompt_default() {
1314 let config = PawanConfig::default();
1315 let prompt = config.get_system_prompt();
1316 assert!(
1317 prompt.contains("Pawan"),
1318 "default prompt should mention Pawan"
1319 );
1320 assert!(
1321 prompt.contains("coding"),
1322 "default prompt should mention coding"
1323 );
1324 }
1325
1326 #[test]
1327 fn test_system_prompt_custom_override() {
1328 let config = PawanConfig {
1329 system_prompt: Some("Custom system prompt.".into()),
1330 ..Default::default()
1331 };
1332 let prompt = config.get_system_prompt();
1333 assert!(prompt.starts_with("Custom system prompt."));
1334 }
1335
1336 #[test]
1339 fn test_config_with_cloud_fallback() {
1340 let toml = r#"
1341model = "qwen/qwen3.5-122b-a10b"
1342[cloud]
1343provider = "nvidia"
1344model = "minimaxai/minimax-m2.5"
1345"#;
1346 let config: PawanConfig = toml::from_str(toml).expect("should parse");
1347 assert_eq!(config.model, "qwen/qwen3.5-122b-a10b");
1348 let cloud = config.cloud.unwrap();
1349 assert_eq!(cloud.model, "minimaxai/minimax-m2.5");
1350 }
1351
1352 #[test]
1353 fn test_config_with_healing() {
1354 let toml = r#"
1355model = "test"
1356[healing]
1357fix_errors = true
1358fix_warnings = false
1359fix_tests = true
1360"#;
1361 let config: PawanConfig = toml::from_str(toml).expect("should parse");
1362 assert!(config.healing.fix_errors);
1363 assert!(!config.healing.fix_warnings);
1364 assert!(config.healing.fix_tests);
1365 }
1366
1367 #[test]
1368 fn test_config_defaults_sensible() {
1369 let config = PawanConfig::default();
1370 assert_eq!(config.provider, LlmProvider::Nvidia);
1371 assert!(config.temperature > 0.0 && config.temperature <= 1.0);
1372 assert!(config.max_tokens > 0);
1373 assert!(config.max_tool_iterations > 0);
1374 }
1375
1376 #[test]
1377 fn test_context_file_search_order() {
1378 let config = PawanConfig::default();
1382 let prompt = config.get_system_prompt();
1383 if std::path::Path::new("PAWAN.md").exists() {
1385 assert!(
1386 prompt.contains("Project Context"),
1387 "Should inject project context when PAWAN.md exists"
1388 );
1389 assert!(
1390 prompt.contains("from PAWAN.md"),
1391 "Should identify source as PAWAN.md"
1392 );
1393 }
1394 }
1395
1396 #[test]
1397 fn test_system_prompt_injection_format() {
1398 let config = PawanConfig {
1400 system_prompt: Some("Base prompt.".into()),
1401 ..Default::default()
1402 };
1403 let prompt = config.get_system_prompt();
1404 if prompt.contains("Project Context") {
1406 assert!(
1407 prompt.contains("from "),
1408 "Injection should include source filename"
1409 );
1410 }
1411 }
1412
1413 #[test]
1416 fn test_resolve_skills_repo_env_var_takes_priority() {
1417 let env_dir = tempfile::TempDir::new().expect("tempdir");
1420 let cfg_dir = tempfile::TempDir::new().expect("tempdir");
1421
1422 let config = PawanConfig {
1423 skills_repo: Some(cfg_dir.path().to_path_buf()),
1424 ..Default::default()
1425 };
1426
1427 std::env::set_var("PAWAN_SKILLS_REPO", env_dir.path());
1428 let resolved = config.resolve_skills_repo();
1429 std::env::remove_var("PAWAN_SKILLS_REPO");
1430
1431 let resolved = resolved.expect("env var path should resolve to Some");
1432 assert_eq!(
1433 resolved.canonicalize().unwrap(),
1434 env_dir.path().canonicalize().unwrap(),
1435 "env var should take priority over config.skills_repo"
1436 );
1437 }
1438
1439 #[test]
1440 fn test_resolve_skills_repo_env_var_nonexistent_falls_through() {
1441 let bogus = PathBuf::from("/tmp/pawan-nonexistent-skills-repo-for-test-xyz123");
1448 assert!(!bogus.exists(), "precondition: bogus path must not exist");
1449
1450 let config = PawanConfig {
1451 skills_repo: Some(PathBuf::from("/tmp/pawan-also-nonexistent-abc789")),
1452 ..Default::default()
1453 };
1454
1455 std::env::set_var("PAWAN_SKILLS_REPO", &bogus);
1456 let resolved = config.resolve_skills_repo();
1457 std::env::remove_var("PAWAN_SKILLS_REPO");
1458
1459 if let Some(ref p) = resolved {
1461 assert_ne!(p, &bogus, "nonexistent env var path must not be returned");
1462 assert!(
1463 p.is_dir(),
1464 "any returned path must be an existing directory"
1465 );
1466 }
1467 }
1468
1469 #[test]
1472 fn test_auto_discover_mcp_is_idempotent() {
1473 let mut config = PawanConfig::default();
1477
1478 let first = config.auto_discover_mcp_servers();
1479 let len_after_first = config.mcp.len();
1480
1481 let second = config.auto_discover_mcp_servers();
1482 let len_after_second = config.mcp.len();
1483
1484 assert!(
1485 second.is_empty(),
1486 "second call must discover nothing (got {:?})",
1487 second
1488 );
1489 assert_eq!(
1490 len_after_first, len_after_second,
1491 "mcp map length must not change between calls (first discovered {:?})",
1492 first
1493 );
1494 }
1495
1496 #[test]
1497 fn test_auto_discover_mcp_preserves_existing_entries() {
1498 let mut config = PawanConfig::default();
1502 let custom = McpServerEntry {
1503 command: "custom-eruka".to_string(),
1504 args: vec!["--custom-flag".to_string()],
1505 env: HashMap::new(),
1506 enabled: true,
1507 };
1508 config.mcp.insert("eruka".to_string(), custom);
1509
1510 let discovered = config.auto_discover_mcp_servers();
1511
1512 assert!(
1514 !discovered.contains(&"eruka".to_string()),
1515 "pre-existing 'eruka' entry must not be rediscovered, got {:?}",
1516 discovered
1517 );
1518
1519 let entry = config
1521 .mcp
1522 .get("eruka")
1523 .expect("eruka entry must still exist");
1524 assert_eq!(
1525 entry.command, "custom-eruka",
1526 "custom command must be preserved"
1527 );
1528 assert_eq!(entry.args, vec!["--custom-flag".to_string()]);
1529 }
1530
1531 #[test]
1534 fn test_discover_skills_from_repo_returns_parsed_skills() {
1535 let repo = tempfile::TempDir::new().expect("tempdir");
1538
1539 let skill_dir = repo.path().join("example-skill");
1541 std::fs::create_dir(&skill_dir).expect("mkdir example-skill");
1542 let skill_md = skill_dir.join("SKILL.md");
1543 std::fs::write(
1544 &skill_md,
1545 "---\nname: example-skill\ndescription: A test skill used in pawan unit tests\n---\n# Instructions\n\nDo the thing.\n",
1546 )
1547 .expect("write SKILL.md");
1548
1549 let empty_dir = repo.path().join("not-a-skill");
1551 std::fs::create_dir(&empty_dir).expect("mkdir not-a-skill");
1552
1553 let config = PawanConfig {
1554 skills_repo: Some(repo.path().to_path_buf()),
1555 ..Default::default()
1556 };
1557
1558 std::env::remove_var("PAWAN_SKILLS_REPO");
1560
1561 let skills = config.discover_skills_from_repo();
1562 assert_eq!(
1563 skills.len(),
1564 1,
1565 "expected exactly 1 skill, got {:?}",
1566 skills
1567 );
1568
1569 let (name, desc, path) = &skills[0];
1570 assert_eq!(name, "example-skill");
1571 assert_eq!(desc, "A test skill used in pawan unit tests");
1572 assert_eq!(path, &skill_md);
1573 }
1574
1575 #[test]
1578 fn test_load_with_explicit_pawan_toml_path() {
1579 let tmp = tempfile::TempDir::new().expect("tempdir");
1581 let path = tmp.path().join("pawan.toml");
1582 std::fs::write(
1583 &path,
1584 r#"
1585provider = "nvidia"
1586model = "meta/llama-3.1-405b-instruct"
1587"#,
1588 )
1589 .expect("write pawan.toml");
1590
1591 let config = PawanConfig::load(Some(&path)).expect("load should succeed");
1592 assert_eq!(config.model, "meta/llama-3.1-405b-instruct");
1593 }
1594
1595 #[test]
1596 fn test_load_with_invalid_toml_returns_error() {
1597 let tmp = tempfile::TempDir::new().expect("tempdir");
1599 let path = tmp.path().join("pawan.toml");
1600 std::fs::write(&path, "this is not [[valid] toml @@").expect("write bad toml");
1601
1602 let result = PawanConfig::load(Some(&path));
1603 assert!(result.is_err(), "malformed TOML must return Err");
1604 let err_msg = format!("{}", result.unwrap_err());
1605 assert!(
1606 err_msg.to_lowercase().contains("parse") || err_msg.to_lowercase().contains("failed"),
1607 "error should mention parse/failed, got: {}",
1608 err_msg
1609 );
1610 }
1611
1612 #[test]
1613 fn test_load_with_nonexistent_path_returns_error() {
1614 let bogus = PathBuf::from("/tmp/definitely-does-not-exist-abc123-xyz.toml");
1618 let result = PawanConfig::load(Some(&bogus));
1619 assert!(
1620 result.is_err(),
1621 "non-existent explicit path must return Err"
1622 );
1623 }
1624
1625 #[test]
1626 fn test_load_ares_toml_with_pawan_section() {
1627 let tmp = tempfile::TempDir::new().expect("tempdir");
1629 let path = tmp.path().join("ares.toml");
1630 std::fs::write(
1631 &path,
1632 r#"
1633# ares config (unrelated to pawan)
1634[server]
1635port = 3000
1636
1637[pawan]
1638provider = "ollama"
1639model = "qwen3-coder:30b"
1640"#,
1641 )
1642 .expect("write ares.toml");
1643
1644 let config = PawanConfig::load(Some(&path)).expect("ares.toml load should succeed");
1645 assert_eq!(config.provider, LlmProvider::Ollama);
1646 assert_eq!(config.model, "qwen3-coder:30b");
1647 }
1648
1649 #[test]
1650 fn test_load_ares_toml_without_pawan_section_returns_defaults() {
1651 let tmp = tempfile::TempDir::new().expect("tempdir");
1655 let path = tmp.path().join("ares.toml");
1656 std::fs::write(
1657 &path,
1658 r#"
1659[server]
1660port = 3000
1661workers = 4
1662"#,
1663 )
1664 .expect("write ares.toml without pawan section");
1665
1666 let config = PawanConfig::load(Some(&path)).expect("load should succeed");
1667 let defaults = PawanConfig::default();
1669 assert_eq!(config.provider, defaults.provider);
1670 assert_eq!(config.model, defaults.model);
1671 }
1672
1673 #[test]
1674 fn test_load_empty_toml_file_returns_defaults() {
1675 let tmp = tempfile::TempDir::new().expect("tempdir");
1678 let path = tmp.path().join("pawan.toml");
1679 std::fs::write(&path, "").expect("write empty toml");
1680
1681 let config = PawanConfig::load(Some(&path)).expect("empty toml should load");
1682 let defaults = PawanConfig::default();
1683 assert_eq!(config.provider, defaults.provider);
1684 }
1685}
1686#[test]
1687fn test_default_config_version() {
1688 assert_eq!(default_config_version(), 1);
1689}
1690
1691#[test]
1692fn test_default_tool_idle_timeout() {
1693 assert_eq!(default_tool_idle_timeout(), 300);
1694}
1695
1696#[test]
1697fn test_config_version_field_exists() {
1698 let config = PawanConfig::default();
1699 assert_eq!(config.config_version, 1);
1700}
1701
1702#[test]
1703fn test_tool_idle_timeout_field_exists() {
1704 let config = PawanConfig::default();
1705 assert_eq!(config.tool_call_idle_timeout_secs, 300);
1706}
1707
1708#[test]
1709fn test_migration_result_fields() {
1710 let result = MigrationResult {
1711 migrated: true,
1712 from_version: 0,
1713 to_version: 1,
1714 backup_path: Some(std::path::PathBuf::from("/tmp/backup.toml")),
1715 };
1716 assert!(result.migrated);
1717 assert_eq!(result.from_version, 0);
1718 assert_eq!(result.to_version, 1);
1719 assert!(result.backup_path.is_some());
1720}
1721
1722#[test]
1723fn test_migrate_to_latest_no_migration_needed() {
1724 let mut config = PawanConfig::default();
1725 config.config_version = 1; let result = migrate_to_latest(&mut config, None);
1728
1729 assert!(
1730 !result.migrated,
1731 "Should not migrate if already at latest version"
1732 );
1733 assert_eq!(result.from_version, 1);
1734 assert_eq!(result.to_version, 1);
1735}
1736
1737#[test]
1738fn test_migrate_to_latest_performs_migration() {
1739 let mut config = PawanConfig::default();
1740 config.config_version = 0; let result = migrate_to_latest(&mut config, None);
1743
1744 assert!(result.migrated, "Should migrate from old version");
1745 assert_eq!(result.from_version, 0);
1746 assert_eq!(result.to_version, 1);
1747 assert_eq!(config.config_version, 1, "Config version should be updated");
1748}
1749
1750#[test]
1751fn test_migrate_to_v1_adds_default_fields() {
1752 let mut config = PawanConfig::default();
1753 config.config_version = 0;
1754
1755 let result = migration::migrate_to_v1(&mut config);
1756
1757 assert!(result.is_ok(), "Migration should succeed");
1758 assert_eq!(result.unwrap(), 1, "Should return new version");
1759 assert_eq!(config.config_version, 1, "Config version should be updated");
1760}
1761
1762#[test]
1763fn test_migration_result_no_migration() {
1764 let result = MigrationResult::no_migration(1);
1765
1766 assert!(!result.migrated, "Should indicate no migration");
1767 assert_eq!(result.from_version, 1);
1768 assert_eq!(result.to_version, 1);
1769 assert!(result.backup_path.is_none(), "Should not have backup path");
1770}
1771
1772#[test]
1773fn test_migration_result_with_backup() {
1774 let backup_path = std::path::PathBuf::from("/tmp/backup.toml");
1775 let result = MigrationResult::new(0, 1, Some(backup_path.clone()));
1776
1777 assert!(result.migrated, "Should indicate migration occurred");
1778 assert_eq!(result.from_version, 0);
1779 assert_eq!(result.to_version, 1);
1780 assert_eq!(
1781 result.backup_path,
1782 Some(backup_path),
1783 "Should have backup path"
1784 );
1785}