foundry_mcp/utils/
paths.rs1use anyhow::Result;
4use std::path::Path;
5
6pub 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
24pub 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 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 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 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
79pub 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 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 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 if name.contains("__") {
114 return Err(anyhow::anyhow!(
115 "Feature name cannot contain consecutive underscores"
116 ));
117 }
118
119 Ok(())
120}
121
122pub 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
135pub 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 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}