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