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