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