Skip to main content

perl_dap/config/
mod.rs

1//! Standalone DAP launch and attach configuration structures
2//!
3//! This module provides configuration types for DAP debugging sessions,
4//! supporting both launch (start new process) and attach (connect to running process) modes.
5//!
6//! # Examples
7//!
8//! ## Launch Configuration
9//!
10//! ```no_run
11//! use perl_dap_config::LaunchConfiguration;
12//! use std::path::PathBuf;
13//!
14//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
15//! let mut config = LaunchConfiguration {
16//!     program: PathBuf::from("script.pl"),
17//!     args: vec!["--verbose".to_string()],
18//!     cwd: Some(PathBuf::from("/workspace")),
19//!     env: std::collections::HashMap::new(),
20//!     perl_path: None,
21//!     include_paths: vec![],
22//! };
23//!
24//! config.validate()?;
25//! # Ok(())
26//! # }
27//! ```
28//!
29//! ## Attach Configuration
30//!
31//! ```
32//! use perl_dap_config::AttachConfiguration;
33//!
34//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
35//! let config = AttachConfiguration {
36//!     host: "localhost".to_string(),
37//!     port: 13603,
38//!     timeout_ms: Some(5000),
39//!     stop_on_entry: None,
40//! };
41//!
42//! config.validate()?;
43//! # Ok(())
44//! # }
45//! ```
46
47#![cfg_attr(test, allow(clippy::print_stderr, clippy::print_stdout))]
48
49use anyhow::Result;
50use serde::{Deserialize, Serialize};
51use std::collections::HashMap;
52use std::path::{Path, PathBuf};
53
54/// Validate that a path exists and is a file
55fn validate_file_exists(path: &Path, description: &str) -> Result<()> {
56    if !path.exists() {
57        anyhow::bail!("{} does not exist: {}", description, path.display());
58    }
59    if !path.is_file() {
60        anyhow::bail!("{} is not a file: {}", description, path.display());
61    }
62    Ok(())
63}
64
65/// Validate that a path exists and is a directory
66fn validate_directory_exists(path: &Path, description: &str) -> Result<()> {
67    if !path.exists() {
68        anyhow::bail!("{} does not exist: {}", description, path.display());
69    }
70    if !path.is_dir() {
71        anyhow::bail!("{} is not a directory: {}", description, path.display());
72    }
73    Ok(())
74}
75
76/// Launch configuration for starting a new Perl debugging session
77///
78/// This configuration is used when starting a new Perl process for debugging.
79/// It includes the program path, arguments, environment variables, and Perl-specific
80/// settings like include paths.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82#[serde(rename_all = "camelCase")]
83pub struct LaunchConfiguration {
84    /// Path to the Perl script to debug (required)
85    pub program: PathBuf,
86
87    /// Command-line arguments to pass to the script
88    #[serde(default)]
89    pub args: Vec<String>,
90
91    /// Working directory for the debugged process
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub cwd: Option<PathBuf>,
94
95    /// Environment variables to set for the debugged process
96    #[serde(default)]
97    pub env: HashMap<String, String>,
98
99    /// Path to the perl binary (defaults to "perl" on PATH)
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub perl_path: Option<PathBuf>,
102
103    /// Additional paths to add to @INC (Perl's include path)
104    #[serde(default)]
105    pub include_paths: Vec<PathBuf>,
106}
107
108impl LaunchConfiguration {
109    /// Resolve workspace-relative paths to absolute paths
110    ///
111    /// This method converts relative paths in the configuration to absolute paths
112    /// based on the workspace root. It handles:
113    /// - Program path resolution
114    /// - Working directory resolution
115    /// - Include path resolution
116    ///
117    /// # Arguments
118    ///
119    /// * `workspace_root` - The workspace root directory
120    ///
121    /// # Errors
122    ///
123    /// Returns an error if path resolution fails
124    ///
125    /// # Examples
126    ///
127    /// ```
128    /// use perl_dap_config::LaunchConfiguration;
129    /// use std::path::PathBuf;
130    ///
131    /// # fn main() -> anyhow::Result<()> {
132    /// let mut config = LaunchConfiguration {
133    ///     program: PathBuf::from("script.pl"),
134    ///     args: vec![],
135    ///     cwd: None,
136    ///     env: std::collections::HashMap::new(),
137    ///     perl_path: None,
138    ///     include_paths: vec![PathBuf::from("lib")],
139    /// };
140    ///
141    /// config.resolve_paths(&PathBuf::from("/workspace"))?;
142    /// assert!(config.program.is_absolute());
143    /// # Ok(())
144    /// # }
145    /// ```
146    pub fn resolve_paths(&mut self, workspace_root: &Path) -> Result<()> {
147        // Resolve program path
148        if !self.program.is_absolute() {
149            self.program = workspace_root.join(&self.program);
150        }
151
152        // Resolve working directory
153        if let Some(ref mut cwd) = self.cwd
154            && !cwd.is_absolute()
155        {
156            *cwd = workspace_root.join(&cwd);
157        }
158
159        // Resolve include paths
160        for include_path in &mut self.include_paths {
161            if !include_path.is_absolute() {
162                *include_path = workspace_root.join(&include_path);
163            }
164        }
165
166        Ok(())
167    }
168
169    /// Validate the configuration
170    ///
171    /// This method checks that:
172    /// - Program path exists and is a file
173    /// - Working directory exists (if specified)
174    /// - Perl binary exists (if specified)
175    ///
176    /// # Errors
177    ///
178    /// Returns an error if validation fails
179    ///
180    /// # Examples
181    ///
182    /// ```no_run
183    /// use perl_dap_config::LaunchConfiguration;
184    /// use std::path::PathBuf;
185    ///
186    /// # fn main() -> anyhow::Result<()> {
187    /// let config = LaunchConfiguration {
188    ///     program: PathBuf::from("/path/to/script.pl"),
189    ///     args: vec![],
190    ///     cwd: None,
191    ///     env: std::collections::HashMap::new(),
192    ///     perl_path: None,
193    ///     include_paths: vec![],
194    /// };
195    ///
196    /// config.validate()?;
197    /// # Ok(())
198    /// # }
199    /// ```
200    pub fn validate(&self) -> Result<()> {
201        // Verify program exists
202        validate_file_exists(&self.program, "Program file")?;
203
204        // Verify working directory exists (if specified)
205        if let Some(ref cwd) = self.cwd {
206            validate_directory_exists(cwd, "Working directory")?;
207        }
208
209        // Verify perl binary exists (if specified)
210        if let Some(ref perl_path) = self.perl_path {
211            validate_file_exists(perl_path, "Perl binary")?;
212        }
213
214        Ok(())
215    }
216}
217
218/// Attach configuration for connecting to a running Perl debugging session
219///
220/// This configuration is used when attaching to an already-running Perl process
221/// that has been started with the Perl::LanguageServer DAP module.
222#[derive(Debug, Clone, Serialize, Deserialize)]
223#[serde(rename_all = "camelCase")]
224pub struct AttachConfiguration {
225    /// Host to connect to (typically "localhost")
226    pub host: String,
227
228    /// Port number for the DAP server (default: 13603)
229    pub port: u16,
230
231    /// Connection timeout in milliseconds (optional)
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub timeout_ms: Option<u32>,
234
235    /// If true, pause execution at the first opportunity after attaching.
236    ///
237    /// Equivalent to the DAP `stopOnEntry` field. When set, the adapter emits a
238    /// `stopped` event with `reason = "entry"` immediately after the attach
239    /// handshake completes. Defaults to `false` when absent.
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub stop_on_entry: Option<bool>,
242}
243
244impl Default for AttachConfiguration {
245    fn default() -> Self {
246        Self {
247            host: "localhost".to_string(),
248            port: 13603,
249            timeout_ms: Some(5000),
250            stop_on_entry: None,
251        }
252    }
253}
254
255impl AttachConfiguration {
256    /// Validate the attach configuration
257    ///
258    /// This method checks that:
259    /// - Host is not empty
260    /// - Port is in valid range (1-65535)
261    /// - Timeout is reasonable (if specified)
262    ///
263    /// # Errors
264    ///
265    /// Returns an error if validation fails
266    ///
267    /// # Examples
268    ///
269    /// ```
270    /// use perl_dap_config::AttachConfiguration;
271    ///
272    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
273    /// let config = AttachConfiguration {
274    ///     host: "localhost".to_string(),
275    ///     port: 13603,
276    ///     timeout_ms: Some(5000),
277    ///     stop_on_entry: None,
278    /// };
279    ///
280    /// config.validate()?;
281    /// # Ok(())
282    /// # }
283    /// ```
284    pub fn validate(&self) -> Result<()> {
285        // Verify host is not empty
286        if self.host.trim().is_empty() {
287            anyhow::bail!("Host cannot be empty");
288        }
289
290        // Port is u16, so it's automatically in range 0-65535
291        // But we should reject port 0 as it's not valid for connecting
292        if self.port == 0 {
293            anyhow::bail!("Port must be in range 1-65535");
294        }
295
296        // Verify timeout is reasonable (if specified)
297        if let Some(timeout) = self.timeout_ms {
298            if timeout == 0 {
299                anyhow::bail!("Timeout must be greater than 0 milliseconds");
300            }
301            if timeout > 300_000 {
302                // 5 minutes max
303                anyhow::bail!("Timeout cannot exceed 300000 milliseconds (5 minutes)");
304            }
305        }
306
307        Ok(())
308    }
309}
310
311/// Create a launch.json configuration snippet
312///
313/// This function generates a JSON snippet suitable for use in VS Code's launch.json
314/// file. The snippet includes placeholders for the program path and other common options.
315///
316/// # Returns
317///
318/// A JSON string containing the launch configuration template
319///
320/// # Examples
321///
322/// ```
323/// use perl_dap_config::create_launch_json_snippet;
324///
325/// let snippet = create_launch_json_snippet();
326/// assert!(snippet.contains("\"type\""));
327/// assert!(snippet.contains("\"launch\""));
328/// ```
329pub fn create_launch_json_snippet() -> String {
330    let json = serde_json::json!({
331        "type": "perl",
332        "request": "launch",
333        "name": "Launch Perl Script",
334        "program": "${workspaceFolder}/script.pl",
335        "args": [],
336        "perlPath": "perl",
337        "includePaths": ["${workspaceFolder}/lib"],
338        "cwd": "${workspaceFolder}",
339        "env": {}
340    });
341    serde_json::to_string_pretty(&json).unwrap_or_else(|e| {
342        tracing::error!(error = %e, "Failed to serialize launch.json snippet");
343        "{}".to_string()
344    })
345}
346
347/// Create an attach.json configuration snippet
348///
349/// This function generates a JSON snippet for attaching to a running Perl::LanguageServer
350/// DAP session via TCP.
351///
352/// # Returns
353///
354/// A JSON string containing the attach configuration template
355///
356/// # Examples
357///
358/// ```
359/// use perl_dap_config::create_attach_json_snippet;
360///
361/// let snippet = create_attach_json_snippet();
362/// assert!(snippet.contains("\"type\""));
363/// assert!(snippet.contains("\"attach\""));
364/// assert!(snippet.contains("13603"));
365/// ```
366pub fn create_attach_json_snippet() -> String {
367    let json = serde_json::json!({
368        "type": "perl",
369        "request": "attach",
370        "name": "Attach to Perl::LanguageServer",
371        "host": "localhost",
372        "port": 13603,
373        "timeout": 5000,
374        "stopOnEntry": false
375    });
376    serde_json::to_string_pretty(&json).unwrap_or_else(|e| {
377        tracing::error!(error = %e, "Failed to serialize attach.json snippet");
378        "{}".to_string()
379    })
380}