git_claw/infra/
env_file.rs1use crate::core::config::EnvironmentConfig;
4use std::collections::{BTreeMap, BTreeSet};
5use std::fs;
6use std::path::Path;
7
8pub fn sanitize_compose_project_name(repo: &str, worktree_name: &str, slot_id: u32) -> String {
10 let raw = format!("{}_{}_{}", repo, worktree_name, slot_id);
11 raw.chars()
12 .map(|c| {
13 if c.is_ascii_alphanumeric() {
14 c.to_ascii_lowercase()
15 } else {
16 '_'
17 }
18 })
19 .collect()
20}
21
22pub fn write_env_worktree(
50 worktree_path: &Path,
51 repo_name: &str,
52 worktree_name: &str,
53 slot_id: u32,
54 effective_ports: &BTreeMap<String, u16>,
55 environment: Option<&EnvironmentConfig>,
56) -> std::io::Result<()> {
57 let compose_project = sanitize_compose_project_name(repo_name, worktree_name, slot_id);
58 let env_path = worktree_path.join(".env.worktree");
59
60 let mut lines = Vec::new();
61 lines.push(format!("CLAW_SLOT_ID={}", slot_id));
62 lines.push(format!("CLAW_WORKTREE_NAME={}", worktree_name));
63 lines.push(format!("CLAW_WORKTREE_PATH={}", worktree_path.display()));
64 lines.push(format!("COMPOSE_PROJECT_NAME={}", compose_project));
65
66 for (key, port) in effective_ports {
67 lines.push(format!("{}={}", key, port));
68 }
69
70 if let Some(env) = environment {
71 if let Some(ref name) = env.name {
72 lines.push(format!("PYENV_VERSION={}", name));
73 }
74 }
75
76 let venv_path = worktree_path.join(".venv");
77 if venv_path.exists() {
78 lines.push(format!("VIRTUAL_ENV={}", venv_path.display()));
79 lines.push(format!("PATH={}/bin:$PATH", venv_path.display()));
80 }
81
82 let content = lines.join("\n") + "\n";
83 fs::write(&env_path, content)?;
84
85 Ok(())
86}
87
88pub fn update_env_content_with_ports(
91 content: &str,
92 raw_ports: &BTreeMap<String, u16>,
93 slot_id: u32,
94 compose_project_name: Option<&str>,
95) -> String {
96 let mut updated_lines = Vec::new();
97 let mut matched_ports = BTreeSet::new();
98 let mut matched_compose_project = false;
99
100 let mut effective_map = BTreeMap::new();
101 for (k, v) in raw_ports {
102 let eff = v.saturating_add(slot_id as u16);
103 let formatted = crate::core::port::format_port_env_key(k);
104 effective_map.insert(k.to_ascii_uppercase(), (k.clone(), eff));
105 effective_map.insert(formatted, (k.clone(), eff));
106 }
107
108 for line in content.lines() {
109 let trimmed = line.trim();
110 if trimmed.is_empty() || trimmed.starts_with('#') {
111 updated_lines.push(line.to_string());
112 continue;
113 }
114
115 if let Some((key, _val)) = line.split_once('=') {
116 let key_trimmed = key.trim();
117 let key_upper = key_trimmed.to_ascii_uppercase();
118
119 if key_upper == "COMPOSE_PROJECT_NAME" {
120 if let Some(proj) = compose_project_name {
121 updated_lines.push(format!("COMPOSE_PROJECT_NAME={}", proj));
122 matched_compose_project = true;
123 continue;
124 }
125 }
126
127 if let Some((orig_key, eff_port)) = effective_map.get(&key_upper) {
128 updated_lines.push(format!("{}={}", key.trim_end(), eff_port));
129 matched_ports.insert(orig_key.clone());
130 } else {
131 updated_lines.push(line.to_string());
132 }
133 } else {
134 updated_lines.push(line.to_string());
135 }
136 }
137
138 for (raw_key, base_port) in raw_ports {
140 if !matched_ports.contains(raw_key) {
141 let eff = base_port.saturating_add(slot_id as u16);
142 let formatted_key = crate::core::port::format_port_env_key(raw_key);
143 updated_lines.push(format!("{}={}", formatted_key, eff));
144 }
145 }
146
147 if let Some(proj) = compose_project_name {
149 if !matched_compose_project {
150 updated_lines.push(format!("COMPOSE_PROJECT_NAME={}", proj));
151 }
152 }
153
154 let mut result = updated_lines.join("\n");
155 if !result.ends_with('\n') {
156 result.push('\n');
157 }
158 result
159}
160
161pub fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
163 fs::create_dir_all(dst)?;
164 for entry in fs::read_dir(src)? {
165 let entry = entry?;
166 let file_type = entry.file_type()?;
167 let dest_path = dst.join(entry.file_name());
168 if file_type.is_dir() {
169 copy_dir_all(&entry.path(), &dest_path)?;
170 } else {
171 fs::copy(entry.path(), dest_path)?;
172 }
173 }
174 Ok(())
175}
176
177pub fn copy_and_merge_untracked_files(
181 repo_root: &Path,
182 worktree_path: &Path,
183 files_to_copy: &[String],
184 raw_ports: &BTreeMap<String, u16>,
185 slot_id: u32,
186 compose_project_name: Option<&str>,
187) -> std::io::Result<()> {
188 for rel_path in files_to_copy {
189 let src = repo_root.join(rel_path);
190 let dest = worktree_path.join(rel_path);
191
192 if !src.exists() {
193 continue;
194 }
195
196 if src.is_dir() {
197 copy_dir_all(&src, &dest)?;
198 continue;
199 }
200
201 if let Some(parent) = dest.parent() {
202 fs::create_dir_all(parent)?;
203 }
204
205 let is_env_file = rel_path.ends_with(".env") || rel_path.contains(".env");
206 if is_env_file {
207 let content = fs::read_to_string(&src)?;
208 let updated =
209 update_env_content_with_ports(&content, raw_ports, slot_id, compose_project_name);
210 fs::write(&dest, updated)?;
211 } else {
212 fs::copy(&src, &dest)?;
213 }
214 }
215
216 Ok(())
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 #[test]
224 fn test_sanitize_compose_project_name() {
225 assert_eq!(
226 sanitize_compose_project_name("git-claw", "user-auth", 2),
227 "git_claw_user_auth_2"
228 );
229 assert_eq!(
230 sanitize_compose_project_name("Repo.V1", "feat#1", 1),
231 "repo_v1_feat_1_1"
232 );
233 }
234
235 #[test]
236 fn test_update_env_content_with_ports() {
237 let original = r#"
238# Comments
239SECRET=xyz
240APP_PORT=80
241OTHER=abc
242"#;
243 let mut ports = BTreeMap::new();
244 ports.insert("APP_PORT".to_string(), 80);
245 ports.insert("EXTRA_PORT".to_string(), 9000);
246
247 let updated = update_env_content_with_ports(original, &ports, 2, Some("my_proj_1"));
248 assert!(updated.contains("APP_PORT=82"));
249 assert!(updated.contains("SECRET=xyz"));
250 assert!(updated.contains("EXTRA_PORT=9002"));
251 assert!(updated.contains("COMPOSE_PROJECT_NAME=my_proj_1"));
252 assert!(updated.contains("# Comments"));
253 }
254}