foundry_mcp/utils/
paths.rs

1//! Path manipulation utilities
2
3use anyhow::Result;
4use std::path::Path;
5
6/// Normalize project name to kebab-case
7pub fn normalize_project_name(name: &str) -> String {
8    name.to_lowercase()
9        .chars()
10        .map(|c| {
11            if c.is_alphanumeric() || c == '-' || c == '_' {
12                c
13            } else {
14                '-'
15            }
16        })
17        .collect::<String>()
18        .split('-')
19        .filter(|s| !s.is_empty())
20        .collect::<Vec<&str>>()
21        .join("-")
22}
23
24/// Validate project name format
25pub fn validate_project_name(name: &str) -> Result<()> {
26    if name.is_empty() {
27        return Err(anyhow::anyhow!("Project name cannot be empty"));
28    }
29
30    if name.len() > 50 {
31        return Err(anyhow::anyhow!(
32            "Project name cannot be longer than 50 characters (current: {} characters)",
33            name.len()
34        ));
35    }
36
37    // Check for valid characters (alphanumeric, hyphens only - kebab-case)
38    for c in name.chars() {
39        if !c.is_alphanumeric() && c != '-' {
40            return Err(anyhow::anyhow!(
41                "Project name can only contain lowercase letters, numbers, and hyphens (kebab-case format)"
42            ));
43        }
44        if c.is_alphabetic() && c.is_uppercase() {
45            return Err(anyhow::anyhow!(
46                "Project name must be in kebab-case format (lowercase letters, numbers, and hyphens only)"
47            ));
48        }
49    }
50
51    // Check for consecutive hyphens
52    if name.contains("--") {
53        return Err(anyhow::anyhow!(
54            "Project name cannot contain consecutive hyphens (e.g., 'my--project' is invalid)"
55        ));
56    }
57
58    // Check that it starts and ends with alphanumeric
59    let first_char_valid = name
60        .chars()
61        .next()
62        .map(|c| c.is_alphanumeric())
63        .unwrap_or(false);
64    let last_char_valid = name
65        .chars()
66        .last()
67        .map(|c| c.is_alphanumeric())
68        .unwrap_or(false);
69
70    if !first_char_valid || !last_char_valid {
71        return Err(anyhow::anyhow!(
72            "Project name must start and end with alphanumeric characters (e.g., '-my-project' and 'my-project-' are invalid)"
73        ));
74    }
75
76    Ok(())
77}
78
79/// Validate feature name for specs
80pub fn validate_feature_name(name: &str) -> Result<()> {
81    if name.is_empty() {
82        return Err(anyhow::anyhow!("Feature name cannot be empty"));
83    }
84
85    if name.len() > 50 {
86        return Err(anyhow::anyhow!(
87            "Feature name cannot be longer than 50 characters"
88        ));
89    }
90
91    // Feature names should be snake_case (lowercase alphanumeric with underscores)
92    for c in name.chars() {
93        if !c.is_alphanumeric() && c != '_' {
94            return Err(anyhow::anyhow!(
95                "Feature name can only contain alphanumeric characters and underscores"
96            ));
97        }
98        if c.is_alphabetic() && c.is_uppercase() {
99            return Err(anyhow::anyhow!(
100                "Feature name must be in snake_case (lowercase letters, numbers, and underscores only)"
101            ));
102        }
103    }
104
105    // Can't start or end with underscore
106    if name.starts_with('_') || name.ends_with('_') {
107        return Err(anyhow::anyhow!(
108            "Feature name cannot start or end with an underscore"
109        ));
110    }
111
112    // Can't have consecutive underscores
113    if name.contains("__") {
114        return Err(anyhow::anyhow!(
115            "Feature name cannot contain consecutive underscores"
116        ));
117    }
118
119    Ok(())
120}
121
122/// Get relative path from foundry directory
123pub fn relative_to_foundry(path: &Path) -> Result<String> {
124    let foundry_dir = dirs::home_dir()
125        .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?
126        .join(".foundry");
127
128    let relative_path = path
129        .strip_prefix(&foundry_dir)
130        .map_err(|_| anyhow::anyhow!("Path is not within foundry directory"))?;
131
132    Ok(relative_path.to_string_lossy().to_string())
133}
134
135/// Ensure path is safe (doesn't escape the foundry directory)
136pub fn ensure_safe_path(path: &Path) -> Result<()> {
137    let foundry_dir = dirs::home_dir()
138        .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?
139        .join(".foundry");
140
141    if !path.starts_with(&foundry_dir) {
142        return Err(anyhow::anyhow!("Path is outside of foundry directory"));
143    }
144
145    // Check for path traversal attempts
146    let path_str = path.to_string_lossy();
147    if path_str.contains("../") || path_str.contains("..\\") {
148        return Err(anyhow::anyhow!("Path contains directory traversal"));
149    }
150
151    Ok(())
152}