Skip to main content

pawan/config/
mod.rs

1//! Configuration for Pawan
2//!
3//! Pawan can be configured via:
4//! - `pawan.toml` in the current directory
5//! - `[pawan]` section in `ares.toml`
6//! - Environment variables
7//! - Command line arguments
8
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::PathBuf;
12use tracing;
13
14/// Default config version
15const fn default_config_version() -> u32 {
16    1
17}
18
19/// Default tool idle timeout (5 minutes)
20pub(super) const fn default_tool_idle_timeout() -> u64 {
21    300
22}
23
24
25pub mod migration;
26pub use migration::{migrate_to_latest, save_config, MigrationResult};
27
28/// LLM Provider type
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
30#[serde(rename_all = "lowercase")]
31pub enum LlmProvider {
32    /// NVIDIA API (build.nvidia.com) - default
33    #[default]
34    Nvidia,
35    /// Local Ollama instance
36    Ollama,
37    /// OpenAI-compatible API
38    OpenAI,
39    /// MLX LM server (Apple Silicon native, mlx_lm.server) — auto-routes to localhost:8080
40    Mlx,
41}
42
43/// Main configuration for Pawan
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(default)]
46pub struct PawanConfig {
47    /// Config version for migration tracking (default: 1)
48    #[serde(default = "default_config_version")]
49    pub config_version: u32,
50
51    /// LLM provider to use
52    pub provider: LlmProvider,
53
54    /// LLM model to use for coding tasks
55    pub model: String,
56
57    /// Override the API base URL (e.g. "http://localhost:8080/v1" for llama.cpp).
58    /// Takes priority over OPENAI_API_URL / NVIDIA_API_URL env vars.
59    pub base_url: Option<String>,
60
61    /// Enable dry-run mode (show changes without applying)
62    pub dry_run: bool,
63
64    /// Create backups before editing files
65    pub auto_backup: bool,
66
67    /// Require clean git working directory
68    pub require_git_clean: bool,
69
70    /// Timeout for bash commands (seconds)
71    pub bash_timeout_secs: u64,
72
73    /// Timeout for tool calls that remain idle (seconds)
74    /// Default: 300 (5 minutes)
75    #[serde(default = "default_tool_idle_timeout")]
76    pub tool_call_idle_timeout_secs: u64,
77
78    /// Maximum file size to read (KB)
79    pub max_file_size_kb: usize,
80
81    /// Maximum tool iterations per request
82    pub max_tool_iterations: usize,
83    /// Maximum context tokens before pruning
84    pub max_context_tokens: usize,
85
86    /// System prompt override
87    pub system_prompt: Option<String>,
88
89    /// Temperature for LLM responses
90    pub temperature: f32,
91
92    /// Top-p sampling parameter
93    pub top_p: f32,
94
95    /// Maximum tokens in response
96    pub max_tokens: usize,
97
98    /// Maximum tokens allowed for reasoning/thinking (0 = unlimited).
99    /// When set, pawan tracks thinking vs action token usage per call.
100    /// If thinking exceeds this budget, a warning is logged.
101    pub thinking_budget: usize,
102
103    /// Maximum retries for LLM API calls (429 or 5xx errors)
104    pub max_retries: usize,
105
106    /// Fallback models to try when primary model fails
107    pub fallback_models: Vec<String>,
108    /// Maximum characters in tool result before truncation
109    pub max_result_chars: usize,
110
111    /// Enable reasoning/thinking mode (for DeepSeek/Nemotron models)
112    pub reasoning_mode: bool,
113
114    /// Healing configuration
115    pub healing: HealingConfig,
116
117    /// Target projects
118    pub targets: HashMap<String, TargetConfig>,
119
120    /// TUI configuration
121    pub tui: TuiConfig,
122
123    /// MCP server configurations
124    #[serde(default)]
125    pub mcp: HashMap<String, McpServerEntry>,
126
127    /// Tool permission overrides (tool_name -> permission)
128    #[serde(default)]
129    pub permissions: HashMap<String, ToolPermission>,
130
131    /// Cloud fallback: when primary model fails, fall back to cloud provider.
132    /// Enables hybrid local+cloud routing.
133    pub cloud: Option<CloudConfig>,
134
135    /// Task-type model routing: use different models for different task categories.
136    /// If not set, all tasks use the primary model.
137    #[serde(default)]
138    pub models: ModelRouting,
139
140    /// Eruka context engine integration (3-tier memory injection)
141    #[serde(default)]
142    pub eruka: crate::eruka_bridge::ErukaConfig,
143
144    /// Use ares-server's LLMClient + ToolCoordinator primitives instead of
145    /// pawan's built-in OpenAI-compatible backend. Requires the `ares` feature
146    /// flag to be enabled when building pawan-core. When true, pawan delegates
147    /// LLM generation to ares which provides connection pooling, loop detection,
148    /// and unified multi-provider support. Default: false (backwards compatible).
149    #[serde(default)]
150    pub use_ares_backend: bool,
151    /// Use the ToolCoordinator for tool-calling loops instead of pawan's
152    /// built-in implementation. When true, delegates tool execution to the
153    /// coordinator which provides parallel execution, timeouts, and consistent
154    /// error handling. Default: false (backwards compatible).
155    #[serde(default)]
156    pub use_coordinator: bool,
157
158    /// Optional path to a skills repository (directory of SKILL.md files).
159    ///
160    /// Mirrors the dstack pattern: public repo + private skills linked by
161    /// config. When set, pawan discovers all SKILL.md files under this path
162    /// at runtime via thulp-skill-files SkillLoader. Useful for linking
163    /// private skill libraries without embedding them in the public repo.
164    ///
165    /// Resolution order:
166    ///   1. `PAWAN_SKILLS_REPO` environment variable
167    ///   2. `skills_repo` field in pawan.toml
168    ///   3. `~/.config/pawan/skills` if it exists
169    ///   4. None (no skill discovery beyond the project SKILL.md)
170    #[serde(default)]
171    pub skills_repo: Option<PathBuf>,
172
173    /// Prefer local inference over cloud when a local model server is reachable.
174    /// Before each session pawan probes `local_endpoint` (or the Ollama default
175    /// `http://localhost:11434/v1`) with a 100 ms TCP timeout.
176    /// If the server responds, it is used instead of the configured cloud provider.
177    /// If the server is unreachable the configured provider is used as normal.
178    /// Default: false (always use configured provider).
179    #[serde(default)]
180    pub local_first: bool,
181
182    /// Local inference endpoint URL for the `local_first` probe.
183    /// Must be an OpenAI-compatible `/v1` endpoint (Ollama, llama.cpp, LM Studio, …).
184    /// Defaults to `http://localhost:11434/v1` (Ollama) when not set.
185    #[serde(default)]
186    pub local_endpoint: Option<String>,
187}
188
189/// Task-type model routing — use different models for different task categories.
190///
191/// # Example (pawan.toml)
192/// ```toml
193/// [models]
194/// code = "qwen/qwen3.5-122b-a10b"                  # best for code generation
195/// orchestrate = "minimaxai/minimax-m2.5"            # best for tool calling
196/// execute = "mlx-community/Qwen3.5-9B-OptiQ-4bit"  # fast local execution
197/// ```
198#[derive(Debug, Clone, Default, Serialize, Deserialize)]
199pub struct ModelRouting {
200    /// Model for code generation tasks (implement, refactor, write tests)
201    pub code: Option<String>,
202    /// Model for orchestration tasks (multi-step tool chains, analysis)
203    pub orchestrate: Option<String>,
204    /// Model for simple execution tasks (bash, write_file, cargo test)
205    pub execute: Option<String>,
206}
207
208impl ModelRouting {
209    /// Select the best model for a given task based on keyword analysis.
210    /// Returns None if no routing matches (use default model).
211    pub fn route(&self, query: &str) -> Option<&str> {
212        let q = query.to_lowercase();
213
214        // Code generation patterns
215        if self.code.is_some() {
216            let code_signals = ["implement", "write", "create", "refactor", "fix", "add test",
217                "add function", "struct", "enum", "trait", "algorithm", "data structure"];
218            if code_signals.iter().any(|s| q.contains(s)) {
219                return self.code.as_deref();
220            }
221        }
222
223        // Orchestration patterns
224        if self.orchestrate.is_some() {
225            let orch_signals = ["search", "find", "analyze", "review", "explain", "compare",
226                "list", "check", "verify", "diagnose", "audit"];
227            if orch_signals.iter().any(|s| q.contains(s)) {
228                return self.orchestrate.as_deref();
229            }
230        }
231
232        // Execution patterns
233        if self.execute.is_some() {
234            let exec_signals = ["run", "execute", "bash", "cargo", "test", "build",
235                "deploy", "install", "commit"];
236            if exec_signals.iter().any(|s| q.contains(s)) {
237                return self.execute.as_deref();
238            }
239        }
240
241        None
242    }
243}
244
245/// Cloud fallback configuration for hybrid local+cloud model routing.
246///
247/// When the primary provider (typically a local model via OpenAI-compatible API)
248/// fails or is unavailable, pawan automatically falls back to this cloud provider.
249/// This enables zero-cost local inference with cloud reliability as a safety net.
250///
251/// # Example (pawan.toml)
252/// ```toml
253/// provider = "openai"
254/// model = "Qwen3.5-9B-Q4_K_M"
255///
256/// [cloud]
257/// provider = "nvidia"
258/// model = "mistralai/devstral-2-123b-instruct-2512"
259/// ```
260#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct CloudConfig {
262    /// Cloud LLM provider to fall back to (nvidia or openai)
263    pub provider: LlmProvider,
264    /// Primary cloud model to try first on fallback
265    pub model: String,
266    /// Additional cloud models to try if the primary cloud model also fails
267    #[serde(default)]
268    pub fallback_models: Vec<String>,
269}
270
271/// Permission level for a tool
272#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
273#[serde(rename_all = "lowercase")]
274pub enum ToolPermission {
275    /// Always allow (default for most tools)
276    Allow,
277    /// Deny — tool is disabled
278    Deny,
279    /// Prompt — ask user before executing (TUI shows confirmation, headless denies)
280    Prompt,
281}
282
283impl ToolPermission {
284    /// Resolve permission for a tool name.
285    /// Checks explicit config first, then falls back to default rules:
286    /// - bash, git_commit, write_file, edit_file: Prompt if not explicitly configured
287    /// - Everything else: Allow
288    pub fn resolve(name: &str, permissions: &HashMap<String, ToolPermission>) -> Self {
289        if let Some(p) = permissions.get(name) {
290            return p.clone();
291        }
292        // Default: sensitive tools prompt, others allow
293        match name {
294            "bash" | "git_commit" | "write_file" | "edit_file_lines"
295            | "insert_after" | "append_file" => ToolPermission::Allow, // default allow for now; users can override to Prompt
296            _ => ToolPermission::Allow,
297        }
298    }
299}
300
301impl Default for PawanConfig {
302    fn default() -> Self {
303        let mut targets = HashMap::new();
304        targets.insert(
305            "self".to_string(),
306            TargetConfig {
307                path: PathBuf::from("."),
308                description: "Current project codebase".to_string(),
309            },
310        );
311
312        Self {
313            provider: LlmProvider::Nvidia,
314            config_version: default_config_version(),
315            model: crate::DEFAULT_MODEL.to_string(),
316            base_url: None,
317            dry_run: false,
318            auto_backup: true,
319            require_git_clean: false,
320            bash_timeout_secs: crate::DEFAULT_BASH_TIMEOUT,
321            tool_call_idle_timeout_secs: default_tool_idle_timeout(),
322            max_file_size_kb: 1024,
323            max_tool_iterations: crate::MAX_TOOL_ITERATIONS,
324            max_context_tokens: 100000,
325            system_prompt: None,
326            temperature: 1.0,
327            top_p: 0.95,
328            max_tokens: 8192,
329            thinking_budget: 0, // 0 = unlimited
330            reasoning_mode: true,
331            max_retries: 3,
332            fallback_models: Vec::new(),
333            max_result_chars: 8000,
334            healing: HealingConfig::default(),
335            targets,
336            tui: TuiConfig::default(),
337            mcp: HashMap::new(),
338            permissions: HashMap::new(),
339            cloud: None,
340            models: ModelRouting::default(),
341            eruka: crate::eruka_bridge::ErukaConfig::default(),
342            use_ares_backend: false,
343            use_coordinator: false,
344            skills_repo: None,
345            local_first: false,
346            local_endpoint: None,
347        }
348    }
349}
350
351/// Configuration for self-healing behavior
352#[derive(Debug, Clone, Serialize, Deserialize)]
353#[serde(default)]
354pub struct HealingConfig {
355    /// Automatically commit fixes
356    pub auto_commit: bool,
357
358    /// Fix compilation errors
359    pub fix_errors: bool,
360
361    /// Fix clippy warnings
362    pub fix_warnings: bool,
363
364    /// Fix failing tests
365    pub fix_tests: bool,
366
367    /// Generate missing documentation
368    pub generate_docs: bool,
369
370    /// Run `cargo audit` and surface security advisories as diagnostics.
371    /// Off by default — `cargo audit` requires the binary to be installed
372    /// and has occasional network dependencies for the advisory database.
373    #[serde(default)]
374    pub fix_security: bool,
375
376    /// Maximum fix attempts per issue
377    pub max_attempts: usize,
378
379    /// Optional shell command to run after `cargo check` passes (stage 2 gate).
380    /// If this command exits non-zero the heal loop treats the output as a
381    /// remaining failure and retries.  Useful values:
382    ///   - `"cargo test --workspace"` — run full test suite
383    ///   - `"cargo clippy -- -D warnings"` — enforce zero warnings
384    /// Leave unset (default) to skip the second stage.
385    #[serde(default)]
386    pub verify_cmd: Option<String>,
387}
388
389impl Default for HealingConfig {
390    fn default() -> Self {
391        Self {
392            auto_commit: false,
393            fix_errors: true,
394            fix_warnings: true,
395            fix_tests: true,
396            generate_docs: false,
397            fix_security: false,
398            max_attempts: 3,
399            verify_cmd: None,
400        }
401    }
402}
403
404/// Configuration for a target project
405#[derive(Debug, Clone, Serialize, Deserialize)]
406/// Configuration for a target project
407///
408/// This struct represents configuration for a specific target project that Pawan
409/// can work with. It includes the project path and description.
410pub struct TargetConfig {
411    /// Path to the project root
412    pub path: PathBuf,
413
414    /// Description of the project
415    pub description: String,
416}
417
418/// Configuration for the TUI
419#[derive(Debug, Clone, Serialize, Deserialize)]
420#[serde(default)]
421pub struct TuiConfig {
422    /// Enable syntax highlighting
423    pub syntax_highlighting: bool,
424    
425    /// Theme for syntax highlighting
426    pub theme: String,
427    
428    /// Show line numbers in code blocks
429    pub line_numbers: bool,
430    
431    /// Enable mouse support
432    pub mouse_support: bool,
433    
434    /// Scroll speed (lines per scroll event)
435    pub scroll_speed: usize,
436    
437    /// Maximum history entries to keep
438    pub max_history: usize,
439    
440    /// Auto-save enabled (default: true)
441    pub auto_save_enabled: bool,
442    /// Auto-save interval in minutes
443    pub auto_save_interval_minutes: u32,
444    /// Custom save directory for auto-saves (defaults to ~/.pawan/sessions/)
445    pub auto_save_dir: Option<std::path::PathBuf>,
446}
447
448impl Default for TuiConfig {
449    fn default() -> Self {
450        Self {
451            syntax_highlighting: true,
452            theme: "base16-ocean.dark".to_string(),
453            line_numbers: true,
454            mouse_support: true,
455            scroll_speed: 3,
456            max_history: 1000,
457            auto_save_enabled: true,
458            auto_save_interval_minutes: 5,
459            auto_save_dir: None,
460        }
461    }
462}
463
464/// Configuration for an MCP server in pawan.toml
465#[derive(Debug, Clone, Serialize, Deserialize)]
466/// Configuration for an MCP server in pawan.toml
467///
468/// This struct represents configuration for an MCP (Multi-Cursor Protocol) server
469/// that can be managed by Pawan. It includes the command to run, arguments,
470/// environment variables, and whether the server is enabled.
471pub struct McpServerEntry {
472    /// Command to run
473    pub command: String,
474    /// Command arguments
475    #[serde(default)]
476    pub args: Vec<String>,
477    /// Environment variables
478    #[serde(default)]
479    pub env: HashMap<String, String>,
480    /// Whether this server is enabled
481    #[serde(default = "default_true")]
482    pub enabled: bool,
483}
484
485fn default_true() -> bool {
486    true
487}
488
489impl PawanConfig {
490    /// Load configuration from file
491    pub fn load(path: Option<&PathBuf>) -> crate::Result<Self> {
492        let config_path = path.cloned().or_else(|| {
493            // 1. pawan.toml in CWD
494            let pawan_toml = PathBuf::from("pawan.toml");
495            if pawan_toml.exists() {
496                return Some(pawan_toml);
497            }
498
499            // 2. ares.toml in CWD
500            let ares_toml = PathBuf::from("ares.toml");
501            if ares_toml.exists() {
502                return Some(ares_toml);
503            }
504
505            // 3. Global user config: ~/.config/pawan/pawan.toml
506            if let Some(home) = dirs::home_dir() {
507                let global = home.join(".config/pawan/pawan.toml");
508                if global.exists() {
509                    return Some(global);
510                }
511            }
512
513            None
514        });
515
516        match config_path {
517            Some(path) => {
518                let content = std::fs::read_to_string(&path).map_err(|e| {
519                    crate::PawanError::Config(format!("Failed to read {}: {}", path.display(), e))
520                })?;
521
522                // Check if this is ares.toml (look for [pawan] section)
523                if path.file_name().map(|n| n == "ares.toml").unwrap_or(false) {
524                    // Parse as TOML and extract [pawan] section
525                    let value: toml::Value = toml::from_str(&content).map_err(|e| {
526                        crate::PawanError::Config(format!(
527                            "Failed to parse {}: {}",
528                            path.display(),
529                            e
530                        ))
531                    })?;
532
533                    if let Some(pawan_section) = value.get("pawan") {
534                        let config: PawanConfig =
535                            pawan_section.clone().try_into().map_err(|e| {
536                                crate::PawanError::Config(format!(
537                                    "Failed to parse [pawan] section: {}",
538                                    e
539                                ))
540                            })?;
541                        return Ok(config);
542                    }
543
544                    // No [pawan] section, use defaults
545                    Ok(Self::default())
546                } else {
547                    // Parse as pawan.toml
548                    let mut config: PawanConfig = toml::from_str(&content).map_err(|e| {
549                        crate::PawanError::Config(format!(
550                            "Failed to parse {}: {}",
551                            path.display(),
552                            e
553                        ))
554                    })?;
555
556                    // Migrate config to latest version
557                    let migration_result = migrate_to_latest(&mut config, Some(&path));
558                    if migration_result.migrated {
559                        tracing::info!(
560                            from_version = migration_result.from_version,
561                            to_version = migration_result.to_version,
562                            backup = ?migration_result.backup_path,
563                            "Config migrated"
564                        );
565
566                        // Save migrated config
567                        if let Err(e) = save_config(&config, &path) {
568                            tracing::warn!(error = %e, "Failed to save migrated config");
569                        }
570                    }
571
572                    Ok(config)
573                }
574            }
575            None => Ok(Self::default()),
576        }
577    }
578
579    /// Apply environment variable overrides (PAWAN_MODEL, PAWAN_PROVIDER, etc.)
580    pub fn apply_env_overrides(&mut self) {
581        if let Ok(model) = std::env::var("PAWAN_MODEL") {
582            self.model = model;
583        }
584        if let Ok(provider) = std::env::var("PAWAN_PROVIDER") {
585            match provider.to_lowercase().as_str() {
586                "nvidia" | "nim" => self.provider = LlmProvider::Nvidia,
587                "ollama" => self.provider = LlmProvider::Ollama,
588                "openai" => self.provider = LlmProvider::OpenAI,
589                "mlx" | "mlx-lm" => self.provider = LlmProvider::Mlx,
590                _ => tracing::warn!(provider = provider.as_str(), "Unknown PAWAN_PROVIDER, ignoring"),
591            }
592        }
593        if let Ok(temp) = std::env::var("PAWAN_TEMPERATURE") {
594            if let Ok(t) = temp.parse::<f32>() {
595                self.temperature = t;
596            }
597        }
598        if let Ok(tokens) = std::env::var("PAWAN_MAX_TOKENS") {
599            if let Ok(t) = tokens.parse::<usize>() {
600                self.max_tokens = t;
601            }
602        }
603        if let Ok(iters) = std::env::var("PAWAN_MAX_ITERATIONS") {
604            if let Ok(i) = iters.parse::<usize>() {
605                self.max_tool_iterations = i;
606            }
607        }
608        if let Ok(ctx) = std::env::var("PAWAN_MAX_CONTEXT_TOKENS") {
609            if let Ok(c) = ctx.parse::<usize>() {
610                self.max_context_tokens = c;
611            }
612        }
613        if let Ok(models) = std::env::var("PAWAN_FALLBACK_MODELS") {
614            self.fallback_models = models.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
615        }
616        if let Ok(chars) = std::env::var("PAWAN_MAX_RESULT_CHARS") {
617            if let Ok(c) = chars.parse::<usize>() {
618                self.max_result_chars = c;
619            }
620        }
621    }
622
623    /// Get target by name
624    pub fn get_target(&self, name: &str) -> Option<&TargetConfig> {
625        self.targets.get(name)
626    }
627
628    /// Get the system prompt, with optional project context injection.
629    /// Loads from PAWAN.md, AGENTS.md, CLAUDE.md, or .pawan/context.md.
630    pub fn get_system_prompt(&self) -> String {
631        let base = self
632            .system_prompt
633            .clone()
634            .unwrap_or_else(|| DEFAULT_SYSTEM_PROMPT.to_string());
635
636        let mut prompt = base;
637
638        if let Some((filename, ctx)) = Self::load_context_file() {
639            prompt = format!("{}\n\n## Project Context (from {})\n\n{}", prompt, filename, ctx);
640        }
641
642        if let Some(skill_ctx) = Self::load_skill_context() {
643            prompt = format!("{}\n\n## Active Skill (from SKILL.md)\n\n{}", prompt, skill_ctx);
644        }
645
646        prompt
647    }
648
649    /// Load project context file from current directory (if it exists).
650    /// Checks PAWAN.md, AGENTS.md (cross-tool standard), CLAUDE.md, then .pawan/context.md.
651    /// Returns (filename, content) of the first found file.
652    fn load_context_file() -> Option<(String, String)> {
653        for path in &["PAWAN.md", "AGENTS.md", "CLAUDE.md", ".pawan/context.md"] {
654            let p = PathBuf::from(path);
655            if p.exists() {
656                if let Ok(content) = std::fs::read_to_string(&p) {
657                    if !content.trim().is_empty() {
658                        return Some((path.to_string(), content));
659                    }
660                }
661            }
662        }
663        None
664    }
665
666    /// Load SKILL.md files from the project using thulp-skill-files.
667    /// Returns a summary of discovered skills for context injection.
668    /// Sibling of `load_context_file`; only called from `get_system_prompt`.
669    fn load_skill_context() -> Option<String> {
670        use thulp_skill_files::SkillFile;
671
672        let skill_path = std::path::Path::new("SKILL.md");
673        if !skill_path.exists() {
674            return None;
675        }
676
677        match SkillFile::parse(skill_path) {
678            Ok(skill) => {
679                let name = skill.effective_name();
680                let desc = skill.frontmatter.description.as_deref().unwrap_or("no description");
681                let tools_str = match &skill.frontmatter.allowed_tools {
682                    Some(tools) => tools.join(", "),
683                    None => "all".to_string(),
684                };
685                Some(format!(
686                    "[Skill: {}] {}\nAllowed tools: {}\n---\n{}",
687                    name, desc, tools_str, skill.content
688                ))
689            }
690            Err(e) => {
691                tracing::warn!("Failed to parse SKILL.md: {}", e);
692                None
693            }
694        }
695    }
696
697    /// Resolve the effective skills repository path using the dstack pattern:
698    /// env var > config field > default `~/.config/pawan/skills` > None.
699    ///
700    /// Returns `Some(path)` only if the resolved path exists as a directory.
701    /// This allows public pawan repos to link to private skill libraries
702    /// without embedding them — the path is configured per-machine.
703    pub fn resolve_skills_repo(&self) -> Option<PathBuf> {
704        // 1. Environment variable has highest priority
705        if let Ok(env_path) = std::env::var("PAWAN_SKILLS_REPO") {
706            let p = PathBuf::from(env_path);
707            if p.is_dir() {
708                return Some(p);
709            }
710            tracing::warn!(path = %p.display(), "PAWAN_SKILLS_REPO set but directory does not exist");
711        }
712
713        // 2. Config field
714        if let Some(ref p) = self.skills_repo {
715            if p.is_dir() {
716                return Some(p.clone());
717            }
718            tracing::warn!(path = %p.display(), "config.skills_repo set but directory does not exist");
719        }
720
721        // 3. Default: ~/.config/pawan/skills
722        if let Some(home) = dirs::home_dir() {
723            let default = home.join(".config").join("pawan").join("skills");
724            if default.is_dir() {
725                return Some(default);
726            }
727        }
728
729        None
730    }
731
732    /// Auto-discover MCP server binaries in PATH and register any that aren't
733    /// already configured. Returns the names of newly-discovered servers.
734    ///
735    /// Supported auto-discovery targets:
736    /// - `eruka-mcp` — Eruka context memory (anti-hallucination knowledge store)
737    /// - `daedra` — web search across 9 backends
738    /// - `deagle-mcp` — deagle code intelligence graph
739    ///
740    /// Existing entries in the `mcp` HashMap are never overwritten. This makes
741    /// the auto-discovery idempotent and safe to call at every agent startup.
742    pub fn auto_discover_mcp_servers(&mut self) -> Vec<String> {
743        let mut discovered = Vec::new();
744
745        // eruka-mcp: context memory for anti-hallucination
746        if !self.mcp.contains_key("eruka") && which::which("eruka-mcp").is_ok() {
747            self.mcp.insert(
748                "eruka".to_string(),
749                McpServerEntry {
750                    command: "eruka-mcp".to_string(),
751                    args: vec!["--transport".to_string(), "stdio".to_string()],
752                    env: HashMap::new(),
753                    enabled: true,
754                },
755            );
756            discovered.push("eruka".to_string());
757            tracing::info!("auto-discovered eruka-mcp");
758        }
759
760        // daedra: web search with 9 backends + fallback
761        if !self.mcp.contains_key("daedra") && which::which("daedra").is_ok() {
762            self.mcp.insert(
763                "daedra".to_string(),
764                McpServerEntry {
765                    command: "daedra".to_string(),
766                    args: vec![
767                        "serve".to_string(),
768                        "--transport".to_string(),
769                        "stdio".to_string(),
770                        "--quiet".to_string(),
771                    ],
772                    env: HashMap::new(),
773                    enabled: true,
774                },
775            );
776            discovered.push("daedra".to_string());
777            tracing::info!("auto-discovered daedra");
778        }
779
780        // deagle-mcp: graph-backed code intelligence
781        if !self.mcp.contains_key("deagle") && which::which("deagle-mcp").is_ok() {
782            self.mcp.insert(
783                "deagle".to_string(),
784                McpServerEntry {
785                    command: "deagle-mcp".to_string(),
786                    args: vec!["--transport".to_string(), "stdio".to_string()],
787                    env: HashMap::new(),
788                    enabled: true,
789                },
790            );
791            discovered.push("deagle".to_string());
792            tracing::info!("auto-discovered deagle-mcp");
793        }
794
795        discovered
796    }
797
798    /// Discover all SKILL.md files in the configured skills repository using
799    /// thulp-skill-files SkillLoader.
800    ///
801    /// Returns a list of `(skill_name, description, file_path)` tuples. The
802    /// caller is responsible for deciding which skills to inject into the
803    /// system prompt or present to the user.
804    ///
805    /// The skills repository is never compiled into the pawan binary — this
806    /// enables the "public repo links to private skills via config" pattern
807    /// used by dstack for `dirmacs/skills`.
808    pub fn discover_skills_from_repo(&self) -> Vec<(String, String, PathBuf)> {
809        use thulp_skill_files::SkillFile;
810
811        let repo = match self.resolve_skills_repo() {
812            Some(r) => r,
813            None => return Vec::new(),
814        };
815
816        let mut results = Vec::new();
817        let walker = match std::fs::read_dir(&repo) {
818            Ok(w) => w,
819            Err(e) => {
820                tracing::warn!(path = %repo.display(), error = %e, "failed to read skills repo");
821                return Vec::new();
822            }
823        };
824
825        for entry in walker.flatten() {
826            let path = entry.path();
827            // Each skill is a directory containing SKILL.md
828            let skill_file = path.join("SKILL.md");
829            if !skill_file.is_file() {
830                continue;
831            }
832            match SkillFile::parse(&skill_file) {
833                Ok(skill) => {
834                    let name = skill.effective_name();
835                    let desc = skill
836                        .frontmatter
837                        .description
838                        .clone()
839                        .unwrap_or_else(|| "(no description)".to_string());
840                    results.push((name, desc, skill_file));
841                }
842                Err(e) => {
843                    tracing::debug!(path = %skill_file.display(), error = %e, "skip unparseable skill");
844                }
845            }
846        }
847
848        results.sort_by(|a, b| a.0.cmp(&b.0));
849        results
850    }
851
852    /// Check if thinking mode should be enabled.
853    /// Applicable to DeepSeek, Gemma-4, GLM, Qwen, and Mistral Small 4+ models on NIM.
854    pub fn use_thinking_mode(&self) -> bool {
855        self.reasoning_mode
856            && (self.model.contains("deepseek")
857                || self.model.contains("gemma")
858                || self.model.contains("glm")
859                || self.model.contains("qwen")
860                || self.model.contains("mistral-small-4"))
861    }
862}
863
864/// Default system prompt for coding tasks
865pub const DEFAULT_SYSTEM_PROMPT: &str = r#"You are Pawan, an expert coding assistant.
866
867# Efficiency
868- Act immediately. Do NOT explore or plan before writing. Write code FIRST, then verify.
869- write_file creates parents automatically. No mkdir needed.
870- cargo check runs automatically after .rs writes — fix errors immediately.
871- Use relative paths from workspace root.
872- Missing tools are auto-installed via mise. Don't check dependencies.
873- You have limited tool iterations. Be direct. No preamble.
874
875# Tool Selection
876Use the BEST tool for the job — do NOT use bash for things dedicated tools handle:
877- File ops: read_file, write_file, edit_file, edit_file_lines, insert_after, append_file, list_directory
878- Code intelligence: ast_grep (AST search + rewrite via tree-sitter — prefer for structural changes)
879- Search: glob_search (files by pattern), grep_search (content by regex), ripgrep (native rg), fd (native find)
880- Shell: bash (commands), sd (find-replace in files), mise (tool/task/env manager), zoxide (smart cd)
881- Git: git_status, git_diff, git_add, git_commit, git_log, git_blame, git_branch, git_checkout, git_stash
882- Agent: spawn_agent (delegate subtask), spawn_agents (parallel sub-agents)
883- Web: mcp_daedra_web_search (ALWAYS use for web queries — never bash+curl)
884
885Prefer ast_grep over edit_file for code refactors. Prefer grep_search over bash grep.
886Prefer fd over bash find. Prefer sd over bash sed.
887
888# Parallel Execution
889Call multiple tools in a single response when they are independent.
890If tool B depends on tool A's result, call them sequentially.
891Never parallelize destructive operations (writes, deletes, commits).
892
893# Read Before Modifying
894Do NOT propose changes to code you haven't read. If asked to modify a file, read it first.
895Understand existing code, patterns, and style before suggesting changes.
896
897# Scope Discipline
898Make minimal, focused changes. Follow existing code style.
899- Don't add features, refactor, or "improve" code beyond what was asked.
900- Don't add docstrings, comments, or type annotations to code you didn't change.
901- A bug fix doesn't need surrounding code cleaned up.
902- Don't add error handling for scenarios that can't happen.
903
904# Executing Actions with Care
905Consider reversibility and blast radius before acting:
906- Freely take local, reversible actions (editing files, running tests).
907- For hard-to-reverse actions (force-push, rm -rf, dropping tables), ask first.
908- Match the scope of your actions to what was requested.
909- Investigate before deleting — unfamiliar files may be the user's in-progress work.
910- Don't use destructive shortcuts to bypass safety checks.
911
912# Git Safety
913- NEVER skip hooks (--no-verify) unless explicitly asked.
914- ALWAYS create NEW commits rather than amending (amend after hook failure destroys work).
915- NEVER force-push to main/master. Warn if requested.
916- Prefer staging specific files over `git add -A` (avoids committing secrets).
917- Only commit when explicitly asked. Don't be over-eager.
918- Commit messages: focus on WHY, not WHAT. Use HEREDOC for multi-line messages.
919- Use the git author from `git config user.name` / `git config user.email`.
920
921# Output Style
922Be concise. Lead with the answer, not the reasoning.
923Focus text output on: decisions needing input, status updates, errors/blockers.
924If you can say it in one sentence, don't use three.
925After .rs writes, cargo check auto-runs — fix errors immediately if it fails.
926Run tests when the task calls for it (cargo test -p <crate>).
927One fix at a time. If it doesn't work, try a different approach."#;
928
929#[cfg(test)]
930mod tests {
931    use super::*;
932
933    #[test]
934    fn test_provider_mlx_parsing() {
935        // "mlx" string parses to LlmProvider::Mlx via serde rename_all = "lowercase"
936        let toml = r#"
937provider = "mlx"
938model = "mlx-community/Qwen3.5-9B-4bit"
939"#;
940        let config: PawanConfig = toml::from_str(toml).expect("should parse without error");
941        assert_eq!(config.provider, LlmProvider::Mlx);
942        assert_eq!(config.model, "mlx-community/Qwen3.5-9B-4bit");
943    }
944
945    #[test]
946    fn test_provider_mlx_lm_alias() {
947        // "mlx-lm" is an alias for mlx via apply_env_overrides (env var path)
948        let mut config = PawanConfig::default();
949        std::env::set_var("PAWAN_PROVIDER", "mlx-lm");
950        config.apply_env_overrides();
951        std::env::remove_var("PAWAN_PROVIDER");
952        assert_eq!(config.provider, LlmProvider::Mlx);
953    }
954
955    #[test]
956    fn test_mlx_base_url_override() {
957        // When provider=mlx and base_url is set, base_url is preserved in config
958        let toml = r#"
959provider = "mlx"
960model = "test-model"
961base_url = "http://192.168.1.100:8080/v1"
962"#;
963        let config: PawanConfig = toml::from_str(toml).expect("should parse without error");
964        assert_eq!(config.provider, LlmProvider::Mlx);
965        assert_eq!(
966            config.base_url.as_deref(),
967            Some("http://192.168.1.100:8080/v1")
968        );
969    }
970
971    // --- ModelRouting tests ---
972
973    #[test]
974    fn test_route_code_signals() {
975        let routing = ModelRouting {
976            code: Some("code-model".into()),
977            orchestrate: Some("orch-model".into()),
978            execute: Some("exec-model".into()),
979        };
980        assert_eq!(routing.route("implement a linked list"), Some("code-model"));
981        assert_eq!(routing.route("refactor the parser"), Some("code-model"));
982        assert_eq!(routing.route("add test for config"), Some("code-model"));
983        assert_eq!(routing.route("Write a new struct"), Some("code-model"));
984    }
985
986    #[test]
987    fn test_route_orchestration_signals() {
988        let routing = ModelRouting {
989            code: Some("code-model".into()),
990            orchestrate: Some("orch-model".into()),
991            execute: Some("exec-model".into()),
992        };
993        assert_eq!(routing.route("analyze the error logs"), Some("orch-model"));
994        assert_eq!(routing.route("review this PR"), Some("orch-model"));
995        assert_eq!(routing.route("explain how the agent works"), Some("orch-model"));
996        assert_eq!(routing.route("search for uses of foo"), Some("orch-model"));
997    }
998
999    #[test]
1000    fn test_route_execution_signals() {
1001        let routing = ModelRouting {
1002            code: Some("code-model".into()),
1003            orchestrate: Some("orch-model".into()),
1004            execute: Some("exec-model".into()),
1005        };
1006        assert_eq!(routing.route("run cargo test"), Some("exec-model"));
1007        assert_eq!(routing.route("execute the deploy script"), Some("exec-model"));
1008        assert_eq!(routing.route("build the project"), Some("exec-model"));
1009        assert_eq!(routing.route("commit these changes"), Some("exec-model"));
1010    }
1011
1012    #[test]
1013    fn test_route_no_match_returns_none() {
1014        let routing = ModelRouting {
1015            code: Some("code-model".into()),
1016            orchestrate: Some("orch-model".into()),
1017            execute: Some("exec-model".into()),
1018        };
1019        assert_eq!(routing.route("hello world"), None);
1020    }
1021
1022    #[test]
1023    fn test_route_empty_routing_returns_none() {
1024        let routing = ModelRouting::default();
1025        assert_eq!(routing.route("implement something"), None);
1026        assert_eq!(routing.route("search for bugs"), None);
1027    }
1028
1029    #[test]
1030    fn test_route_case_insensitive() {
1031        let routing = ModelRouting {
1032            code: Some("code-model".into()),
1033            orchestrate: None,
1034            execute: None,
1035        };
1036        assert_eq!(routing.route("IMPLEMENT a FUNCTION"), Some("code-model"));
1037    }
1038
1039    #[test]
1040    fn test_route_partial_routing() {
1041        // Only code model configured, orch/exec queries return None
1042        let routing = ModelRouting {
1043            code: Some("code-model".into()),
1044            orchestrate: None,
1045            execute: None,
1046        };
1047        assert_eq!(routing.route("implement x"), Some("code-model"));
1048        assert_eq!(routing.route("search for y"), None);
1049        assert_eq!(routing.route("run tests"), None);
1050    }
1051
1052    // --- apply_env_overrides tests ---
1053
1054    #[test]
1055    fn test_env_override_model() {
1056        let mut config = PawanConfig::default();
1057        std::env::set_var("PAWAN_MODEL", "custom/model-123");
1058        config.apply_env_overrides();
1059        std::env::remove_var("PAWAN_MODEL");
1060        assert_eq!(config.model, "custom/model-123");
1061    }
1062
1063    #[test]
1064    fn test_env_override_temperature() {
1065        let mut config = PawanConfig::default();
1066        std::env::set_var("PAWAN_TEMPERATURE", "0.9");
1067        config.apply_env_overrides();
1068        std::env::remove_var("PAWAN_TEMPERATURE");
1069        assert!((config.temperature - 0.9).abs() < f32::EPSILON);
1070    }
1071
1072    #[test]
1073    fn test_env_override_invalid_temperature_ignored() {
1074        let mut config = PawanConfig::default();
1075        let original = config.temperature;
1076        std::env::set_var("PAWAN_TEMPERATURE", "not_a_number");
1077        config.apply_env_overrides();
1078        std::env::remove_var("PAWAN_TEMPERATURE");
1079        assert!((config.temperature - original).abs() < f32::EPSILON);
1080    }
1081
1082    #[test]
1083    fn test_env_override_max_tokens() {
1084        let mut config = PawanConfig::default();
1085        std::env::set_var("PAWAN_MAX_TOKENS", "16384");
1086        config.apply_env_overrides();
1087        std::env::remove_var("PAWAN_MAX_TOKENS");
1088        assert_eq!(config.max_tokens, 16384);
1089    }
1090
1091    #[test]
1092    fn test_env_override_fallback_models() {
1093        std::env::remove_var("PAWAN_FALLBACK_MODELS"); // Clean up before test
1094        let mut config = PawanConfig::default();
1095        std::env::set_var("PAWAN_FALLBACK_MODELS", "model-a, model-b, model-c");
1096        config.apply_env_overrides();
1097        std::env::remove_var("PAWAN_FALLBACK_MODELS");
1098        assert_eq!(config.fallback_models, vec!["model-a", "model-b", "model-c"]);
1099    }
1100
1101    #[test]
1102    fn test_env_override_fallback_models_filters_empty() {
1103        std::env::remove_var("PAWAN_FALLBACK_MODELS"); // Clean up before test
1104        let mut config = PawanConfig::default();
1105        std::env::set_var("PAWAN_FALLBACK_MODELS", "model-a,,, model-b,");
1106        config.apply_env_overrides();
1107        std::env::remove_var("PAWAN_FALLBACK_MODELS");
1108        assert_eq!(config.fallback_models, vec!["model-a", "model-b"]);
1109    }
1110
1111    #[test]
1112    fn test_env_override_provider_variants() {
1113        for (env_val, expected) in [
1114            ("nvidia", LlmProvider::Nvidia),
1115            ("nim", LlmProvider::Nvidia),
1116            ("ollama", LlmProvider::Ollama),
1117            ("openai", LlmProvider::OpenAI),
1118            ("mlx", LlmProvider::Mlx),
1119        ] {
1120            let mut config = PawanConfig::default();
1121            std::env::set_var("PAWAN_PROVIDER", env_val);
1122            config.apply_env_overrides();
1123            std::env::remove_var("PAWAN_PROVIDER");
1124            assert_eq!(config.provider, expected, "PAWAN_PROVIDER={} should map to {:?}", env_val, expected);
1125        }
1126    }
1127
1128    // --- use_thinking_mode tests ---
1129
1130    #[test]
1131    fn test_thinking_mode_supported_models() {
1132        for model in ["deepseek-ai/deepseek-r1", "google/gemma-4-31b-it", "z-ai/glm5",
1133                       "qwen/qwen3.5-122b", "mistralai/mistral-small-4-119b"] {
1134            let config = PawanConfig { model: model.into(), reasoning_mode: true, ..Default::default() };
1135            assert!(config.use_thinking_mode(), "thinking mode should be on for {}", model);
1136        }
1137    }
1138
1139    #[test]
1140    fn test_thinking_mode_disabled_when_reasoning_off() {
1141        let config = PawanConfig { model: "deepseek-ai/deepseek-r1".into(), reasoning_mode: false, ..Default::default() };
1142        assert!(!config.use_thinking_mode());
1143    }
1144
1145    #[test]
1146    fn test_thinking_mode_unsupported_models() {
1147        for model in ["meta/llama-3.1-70b", "minimaxai/minimax-m2.5", "stepfun-ai/step-3.5-flash"] {
1148            let config = PawanConfig { model: model.into(), reasoning_mode: true, ..Default::default() };
1149            assert!(!config.use_thinking_mode(), "thinking mode should be off for {}", model);
1150        }
1151    }
1152
1153    // --- get_system_prompt tests ---
1154
1155    #[test]
1156    fn test_system_prompt_default() {
1157        let config = PawanConfig::default();
1158        let prompt = config.get_system_prompt();
1159        assert!(prompt.contains("Pawan"), "default prompt should mention Pawan");
1160        assert!(prompt.contains("coding"), "default prompt should mention coding");
1161    }
1162
1163    #[test]
1164    fn test_system_prompt_custom_override() {
1165        let config = PawanConfig { system_prompt: Some("Custom system prompt.".into()), ..Default::default() };
1166        let prompt = config.get_system_prompt();
1167        assert!(prompt.starts_with("Custom system prompt."));
1168    }
1169
1170    // --- Config TOML parsing tests ---
1171
1172    #[test]
1173    fn test_config_with_cloud_fallback() {
1174        let toml = r#"
1175model = "qwen/qwen3.5-122b-a10b"
1176[cloud]
1177provider = "nvidia"
1178model = "minimaxai/minimax-m2.5"
1179"#;
1180        let config: PawanConfig = toml::from_str(toml).expect("should parse");
1181        assert_eq!(config.model, "qwen/qwen3.5-122b-a10b");
1182        let cloud = config.cloud.unwrap();
1183        assert_eq!(cloud.model, "minimaxai/minimax-m2.5");
1184    }
1185
1186    #[test]
1187    fn test_config_with_healing() {
1188        let toml = r#"
1189model = "test"
1190[healing]
1191fix_errors = true
1192fix_warnings = false
1193fix_tests = true
1194"#;
1195        let config: PawanConfig = toml::from_str(toml).expect("should parse");
1196        assert!(config.healing.fix_errors);
1197        assert!(!config.healing.fix_warnings);
1198        assert!(config.healing.fix_tests);
1199    }
1200
1201    #[test]
1202    fn test_config_defaults_sensible() {
1203        let config = PawanConfig::default();
1204        assert_eq!(config.provider, LlmProvider::Nvidia);
1205        assert!(config.temperature > 0.0 && config.temperature <= 1.0);
1206        assert!(config.max_tokens > 0);
1207        assert!(config.max_tool_iterations > 0);
1208    }
1209
1210    #[test]
1211    fn test_context_file_search_order() {
1212        // Verify the search list includes all expected files
1213        // (We test the behavior via get_system_prompt since load_context_file is private
1214        // and changing cwd is unsafe in parallel tests)
1215        let config = PawanConfig::default();
1216        let prompt = config.get_system_prompt();
1217        // In the pawan repo, PAWAN.md exists, so it should be in the prompt
1218        if std::path::Path::new("PAWAN.md").exists() {
1219            assert!(prompt.contains("Project Context"), "Should inject project context when PAWAN.md exists");
1220            assert!(prompt.contains("from PAWAN.md"), "Should identify source as PAWAN.md");
1221        }
1222    }
1223
1224    #[test]
1225    fn test_system_prompt_injection_format() {
1226        // Verify the injection format includes the source filename
1227        let config = PawanConfig {
1228            system_prompt: Some("Base prompt.".into()),
1229            ..Default::default()
1230        };
1231        let prompt = config.get_system_prompt();
1232        // If any context file is found, it should show "from <filename>"
1233        if prompt.contains("Project Context") {
1234            assert!(prompt.contains("from "), "Injection should include source filename");
1235        }
1236    }
1237
1238    // --- resolve_skills_repo tests ---
1239
1240    #[test]
1241    fn test_resolve_skills_repo_env_var_takes_priority() {
1242        // PAWAN_SKILLS_REPO pointing at a real tempdir must win over the
1243        // config.skills_repo field (priority 1 in the resolution chain).
1244        let env_dir = tempfile::TempDir::new().expect("tempdir");
1245        let cfg_dir = tempfile::TempDir::new().expect("tempdir");
1246
1247        let config = PawanConfig {
1248            skills_repo: Some(cfg_dir.path().to_path_buf()),
1249            ..Default::default()
1250        };
1251
1252        std::env::set_var("PAWAN_SKILLS_REPO", env_dir.path());
1253        let resolved = config.resolve_skills_repo();
1254        std::env::remove_var("PAWAN_SKILLS_REPO");
1255
1256        let resolved = resolved.expect("env var path should resolve to Some");
1257        assert_eq!(
1258            resolved.canonicalize().unwrap(),
1259            env_dir.path().canonicalize().unwrap(),
1260            "env var should take priority over config.skills_repo"
1261        );
1262    }
1263
1264    #[test]
1265    fn test_resolve_skills_repo_env_var_nonexistent_falls_through() {
1266        // PAWAN_SKILLS_REPO pointing at a nonexistent path must be ignored
1267        // (warning logged) and the function continues to the next priority.
1268        // Here config.skills_repo is also nonexistent, and we cannot control
1269        // ~/.config/pawan/skills from a test, so we only assert that the
1270        // function does NOT panic and returns either None or the default dir
1271        // — crucially it does NOT return the bogus env var path.
1272        let bogus = PathBuf::from("/tmp/pawan-nonexistent-skills-repo-for-test-xyz123");
1273        assert!(!bogus.exists(), "precondition: bogus path must not exist");
1274
1275        let config = PawanConfig {
1276            skills_repo: Some(PathBuf::from("/tmp/pawan-also-nonexistent-abc789")),
1277            ..Default::default()
1278        };
1279
1280        std::env::set_var("PAWAN_SKILLS_REPO", &bogus);
1281        let resolved = config.resolve_skills_repo();
1282        std::env::remove_var("PAWAN_SKILLS_REPO");
1283
1284        // Must never return the bogus path
1285        if let Some(ref p) = resolved {
1286            assert_ne!(p, &bogus, "nonexistent env var path must not be returned");
1287            assert!(p.is_dir(), "any returned path must be an existing directory");
1288        }
1289    }
1290
1291    // --- auto_discover_mcp_servers tests ---
1292
1293    #[test]
1294    fn test_auto_discover_mcp_is_idempotent() {
1295        // Two consecutive calls: first may discover some servers, second must
1296        // return an empty Vec (because all are already registered). The mcp
1297        // hashmap length must be identical between the two calls.
1298        let mut config = PawanConfig::default();
1299
1300        let first = config.auto_discover_mcp_servers();
1301        let len_after_first = config.mcp.len();
1302
1303        let second = config.auto_discover_mcp_servers();
1304        let len_after_second = config.mcp.len();
1305
1306        assert!(
1307            second.is_empty(),
1308            "second call must discover nothing (got {:?})",
1309            second
1310        );
1311        assert_eq!(
1312            len_after_first, len_after_second,
1313            "mcp map length must not change between calls (first discovered {:?})",
1314            first
1315        );
1316    }
1317
1318    #[test]
1319    fn test_auto_discover_mcp_preserves_existing_entries() {
1320        // Pre-populate config.mcp with a custom "eruka" entry. Even if
1321        // which::which("eruka-mcp") would find a binary on the test machine,
1322        // the existing entry MUST NOT be overwritten.
1323        let mut config = PawanConfig::default();
1324        let custom = McpServerEntry {
1325            command: "custom-eruka".to_string(),
1326            args: vec!["--custom-flag".to_string()],
1327            env: HashMap::new(),
1328            enabled: true,
1329        };
1330        config.mcp.insert("eruka".to_string(), custom);
1331
1332        let discovered = config.auto_discover_mcp_servers();
1333
1334        // "eruka" must not appear in the discovered list
1335        assert!(
1336            !discovered.contains(&"eruka".to_string()),
1337            "pre-existing 'eruka' entry must not be rediscovered, got {:?}",
1338            discovered
1339        );
1340
1341        // Custom entry must be intact
1342        let entry = config.mcp.get("eruka").expect("eruka entry must still exist");
1343        assert_eq!(entry.command, "custom-eruka", "custom command must be preserved");
1344        assert_eq!(entry.args, vec!["--custom-flag".to_string()]);
1345    }
1346
1347    // --- discover_skills_from_repo tests ---
1348
1349    #[test]
1350    fn test_discover_skills_from_repo_returns_parsed_skills() {
1351        // Build a skills repo with one valid SKILL.md and verify that
1352        // discover_skills_from_repo parses it via thulp_skill_files::SkillFile.
1353        let repo = tempfile::TempDir::new().expect("tempdir");
1354
1355        // Each skill lives in its own subdirectory containing a SKILL.md
1356        let skill_dir = repo.path().join("example-skill");
1357        std::fs::create_dir(&skill_dir).expect("mkdir example-skill");
1358        let skill_md = skill_dir.join("SKILL.md");
1359        std::fs::write(
1360            &skill_md,
1361            "---\nname: example-skill\ndescription: A test skill used in pawan unit tests\n---\n# Instructions\n\nDo the thing.\n",
1362        )
1363        .expect("write SKILL.md");
1364
1365        // Also drop an empty subdirectory with no SKILL.md — should be skipped
1366        let empty_dir = repo.path().join("not-a-skill");
1367        std::fs::create_dir(&empty_dir).expect("mkdir not-a-skill");
1368
1369        let config = PawanConfig {
1370            skills_repo: Some(repo.path().to_path_buf()),
1371            ..Default::default()
1372        };
1373
1374        // Ensure env var does not interfere
1375        std::env::remove_var("PAWAN_SKILLS_REPO");
1376
1377        let skills = config.discover_skills_from_repo();
1378        assert_eq!(skills.len(), 1, "expected exactly 1 skill, got {:?}", skills);
1379
1380        let (name, desc, path) = &skills[0];
1381        assert_eq!(name, "example-skill");
1382        assert_eq!(desc, "A test skill used in pawan unit tests");
1383        assert_eq!(path, &skill_md);
1384    }
1385
1386    // ─── PawanConfig::load() edge cases (task #24) ──────────────────────
1387
1388    #[test]
1389    fn test_load_with_explicit_pawan_toml_path() {
1390        // Happy path: explicit path to a valid pawan.toml
1391        let tmp = tempfile::TempDir::new().expect("tempdir");
1392        let path = tmp.path().join("pawan.toml");
1393        std::fs::write(
1394            &path,
1395            r#"
1396provider = "nvidia"
1397model = "meta/llama-3.1-405b-instruct"
1398"#,
1399        )
1400        .expect("write pawan.toml");
1401
1402        let config = PawanConfig::load(Some(&path)).expect("load should succeed");
1403        assert_eq!(config.model, "meta/llama-3.1-405b-instruct");
1404    }
1405
1406    #[test]
1407    fn test_load_with_invalid_toml_returns_error() {
1408        // Malformed TOML should return a Config error, not panic
1409        let tmp = tempfile::TempDir::new().expect("tempdir");
1410        let path = tmp.path().join("pawan.toml");
1411        std::fs::write(&path, "this is not [[valid] toml @@").expect("write bad toml");
1412
1413        let result = PawanConfig::load(Some(&path));
1414        assert!(result.is_err(), "malformed TOML must return Err");
1415        let err_msg = format!("{}", result.unwrap_err());
1416        assert!(
1417            err_msg.to_lowercase().contains("parse")
1418                || err_msg.to_lowercase().contains("failed"),
1419            "error should mention parse/failed, got: {}",
1420            err_msg
1421        );
1422    }
1423
1424    #[test]
1425    fn test_load_with_nonexistent_path_returns_error() {
1426        // An explicit path to a file that doesn't exist must return Err,
1427        // not silently fall through to defaults (defaults only apply when
1428        // path=None and no auto-discovered config exists).
1429        let bogus = PathBuf::from("/tmp/definitely-does-not-exist-abc123-xyz.toml");
1430        let result = PawanConfig::load(Some(&bogus));
1431        assert!(
1432            result.is_err(),
1433            "non-existent explicit path must return Err"
1434        );
1435    }
1436
1437    #[test]
1438    fn test_load_ares_toml_with_pawan_section() {
1439        // ares.toml loading must extract the [pawan] section specifically
1440        let tmp = tempfile::TempDir::new().expect("tempdir");
1441        let path = tmp.path().join("ares.toml");
1442        std::fs::write(
1443            &path,
1444            r#"
1445# ares config (unrelated to pawan)
1446[server]
1447port = 3000
1448
1449[pawan]
1450provider = "ollama"
1451model = "qwen3-coder:30b"
1452"#,
1453        )
1454        .expect("write ares.toml");
1455
1456        let config = PawanConfig::load(Some(&path)).expect("ares.toml load should succeed");
1457        assert_eq!(config.provider, LlmProvider::Ollama);
1458        assert_eq!(config.model, "qwen3-coder:30b");
1459    }
1460
1461    #[test]
1462    fn test_load_ares_toml_without_pawan_section_returns_defaults() {
1463        // ares.toml with no [pawan] section must fall back to defaults,
1464        // not error out. This is the common case on VPS where ares runs
1465        // alongside pawan but pawan has its own config elsewhere.
1466        let tmp = tempfile::TempDir::new().expect("tempdir");
1467        let path = tmp.path().join("ares.toml");
1468        std::fs::write(
1469            &path,
1470            r#"
1471[server]
1472port = 3000
1473workers = 4
1474"#,
1475        )
1476        .expect("write ares.toml without pawan section");
1477
1478        let config = PawanConfig::load(Some(&path)).expect("load should succeed");
1479        // Should match defaults
1480        let defaults = PawanConfig::default();
1481        assert_eq!(config.provider, defaults.provider);
1482        assert_eq!(config.model, defaults.model);
1483    }
1484
1485    #[test]
1486    fn test_load_empty_toml_file_returns_defaults() {
1487        // A completely empty pawan.toml is valid TOML and must parse as
1488        // all-defaults via serde(default). This is a common first-run case.
1489        let tmp = tempfile::TempDir::new().expect("tempdir");
1490        let path = tmp.path().join("pawan.toml");
1491        std::fs::write(&path, "").expect("write empty toml");
1492
1493        let config = PawanConfig::load(Some(&path)).expect("empty toml should load");
1494        let defaults = PawanConfig::default();
1495        assert_eq!(config.provider, defaults.provider);
1496    }
1497}
1498    #[test]
1499    fn test_default_config_version() {
1500        assert_eq!(default_config_version(), 1);
1501    }
1502
1503    #[test]
1504    fn test_default_tool_idle_timeout() {
1505        assert_eq!(default_tool_idle_timeout(), 300);
1506    }
1507
1508    #[test]
1509    fn test_config_version_field_exists() {
1510        let config = PawanConfig::default();
1511        assert_eq!(config.config_version, 1);
1512    }
1513
1514    #[test]
1515    fn test_tool_idle_timeout_field_exists() {
1516        let config = PawanConfig::default();
1517        assert_eq!(config.tool_call_idle_timeout_secs, 300);
1518    }
1519
1520    #[test]
1521    fn test_migration_result_fields() {
1522        let result = MigrationResult {
1523            migrated: true,
1524            from_version: 0,
1525            to_version: 1,
1526            backup_path: Some(std::path::PathBuf::from("/tmp/backup.toml")),
1527        };
1528        assert!(result.migrated);
1529        assert_eq!(result.from_version, 0);
1530        assert_eq!(result.to_version, 1);
1531        assert!(result.backup_path.is_some());
1532    }
1533
1534    #[test]
1535    fn test_migrate_to_latest_no_migration_needed() {
1536        let mut config = PawanConfig::default();
1537        config.config_version = 1; // Already at latest version
1538        
1539        let result = migrate_to_latest(&mut config, None);
1540        
1541        assert!(!result.migrated, "Should not migrate if already at latest version");
1542        assert_eq!(result.from_version, 1);
1543        assert_eq!(result.to_version, 1);
1544    }
1545
1546    #[test]
1547    fn test_migrate_to_latest_performs_migration() {
1548        let mut config = PawanConfig::default();
1549        config.config_version = 0; // Old version
1550        
1551        let result = migrate_to_latest(&mut config, None);
1552        
1553        assert!(result.migrated, "Should migrate from old version");
1554        assert_eq!(result.from_version, 0);
1555        assert_eq!(result.to_version, 1);
1556        assert_eq!(config.config_version, 1, "Config version should be updated");
1557    }
1558
1559    #[test]
1560    fn test_migrate_to_v1_adds_default_fields() {
1561        let mut config = PawanConfig::default();
1562        config.config_version = 0;
1563        
1564        let result = migration::migrate_to_v1(&mut config);
1565        
1566        assert!(result.is_ok(), "Migration should succeed");
1567        assert_eq!(result.unwrap(), 1, "Should return new version");
1568        assert_eq!(config.config_version, 1, "Config version should be updated");
1569    }
1570
1571    #[test]
1572    fn test_migration_result_no_migration() {
1573        let result = MigrationResult::no_migration(1);
1574        
1575        assert!(!result.migrated, "Should indicate no migration");
1576        assert_eq!(result.from_version, 1);
1577        assert_eq!(result.to_version, 1);
1578        assert!(result.backup_path.is_none(), "Should not have backup path");
1579    }
1580
1581    #[test]
1582    fn test_migration_result_with_backup() {
1583        let backup_path = std::path::PathBuf::from("/tmp/backup.toml");
1584        let result = MigrationResult::new(0, 1, Some(backup_path.clone()));
1585        
1586        assert!(result.migrated, "Should indicate migration occurred");
1587        assert_eq!(result.from_version, 0);
1588        assert_eq!(result.to_version, 1);
1589        assert_eq!(result.backup_path, Some(backup_path), "Should have backup path");
1590    }
1591