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