git_claw/infra/
env_file.rs1use std::collections::{BTreeMap, BTreeSet};
4use std::fs;
5use std::path::Path;
6
7pub fn sanitize_compose_project_name(repo: &str, worktree_name: &str, slot_id: u32) -> String {
9 let raw = format!("{}_{}_{}", repo, worktree_name, slot_id);
10 raw.chars()
11 .map(|c| {
12 if c.is_ascii_alphanumeric() {
13 c.to_ascii_lowercase()
14 } else {
15 '_'
16 }
17 })
18 .collect()
19}
20
21pub fn write_env_worktree(
23 worktree_path: &Path,
24 repo_name: &str,
25 worktree_name: &str,
26 slot_id: u32,
27 effective_ports: &BTreeMap<String, u16>,
28) -> std::io::Result<()> {
29 let compose_project = sanitize_compose_project_name(repo_name, worktree_name, slot_id);
30 let env_path = worktree_path.join(".env.worktree");
31
32 let mut lines = Vec::new();
33 lines.push(format!("CLAW_SLOT_ID={}", slot_id));
34 lines.push(format!("CLAW_WORKTREE_NAME={}", worktree_name));
35 lines.push(format!("CLAW_WORKTREE_PATH={}", worktree_path.display()));
36 lines.push(format!("COMPOSE_PROJECT_NAME={}", compose_project));
37
38 for (key, port) in effective_ports {
39 lines.push(format!("{}={}", key, port));
40 }
41
42 let content = lines.join("\n") + "\n";
43 fs::write(&env_path, content)?;
44
45 Ok(())
46}
47
48pub fn update_env_content_with_ports(
51 content: &str,
52 raw_ports: &BTreeMap<String, u16>,
53 slot_id: u32,
54 compose_project_name: Option<&str>,
55) -> String {
56 let mut updated_lines = Vec::new();
57 let mut matched_ports = BTreeSet::new();
58 let mut matched_compose_project = false;
59
60 let mut effective_map = BTreeMap::new();
61 for (k, v) in raw_ports {
62 let eff = v.saturating_add(slot_id as u16);
63 let formatted = crate::core::port::format_port_env_key(k);
64 effective_map.insert(k.to_ascii_uppercase(), (k.clone(), eff));
65 effective_map.insert(formatted, (k.clone(), eff));
66 }
67
68 for line in content.lines() {
69 let trimmed = line.trim();
70 if trimmed.is_empty() || trimmed.starts_with('#') {
71 updated_lines.push(line.to_string());
72 continue;
73 }
74
75 if let Some((key, _val)) = line.split_once('=') {
76 let key_trimmed = key.trim();
77 let key_upper = key_trimmed.to_ascii_uppercase();
78
79 if key_upper == "COMPOSE_PROJECT_NAME" {
80 if let Some(proj) = compose_project_name {
81 updated_lines.push(format!("COMPOSE_PROJECT_NAME={}", proj));
82 matched_compose_project = true;
83 continue;
84 }
85 }
86
87 if let Some((orig_key, eff_port)) = effective_map.get(&key_upper) {
88 updated_lines.push(format!("{}={}", key.trim_end(), eff_port));
89 matched_ports.insert(orig_key.clone());
90 } else {
91 updated_lines.push(line.to_string());
92 }
93 } else {
94 updated_lines.push(line.to_string());
95 }
96 }
97
98 for (raw_key, base_port) in raw_ports {
100 if !matched_ports.contains(raw_key) {
101 let eff = base_port.saturating_add(slot_id as u16);
102 let formatted_key = crate::core::port::format_port_env_key(raw_key);
103 updated_lines.push(format!("{}={}", formatted_key, eff));
104 }
105 }
106
107 if let Some(proj) = compose_project_name {
109 if !matched_compose_project {
110 updated_lines.push(format!("COMPOSE_PROJECT_NAME={}", proj));
111 }
112 }
113
114 let mut result = updated_lines.join("\n");
115 if !result.ends_with('\n') {
116 result.push('\n');
117 }
118 result
119}
120
121pub fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
123 fs::create_dir_all(dst)?;
124 for entry in fs::read_dir(src)? {
125 let entry = entry?;
126 let file_type = entry.file_type()?;
127 let dest_path = dst.join(entry.file_name());
128 if file_type.is_dir() {
129 copy_dir_all(&entry.path(), &dest_path)?;
130 } else {
131 fs::copy(entry.path(), dest_path)?;
132 }
133 }
134 Ok(())
135}
136
137pub fn copy_and_merge_untracked_files(
141 repo_root: &Path,
142 worktree_path: &Path,
143 files_to_copy: &[String],
144 raw_ports: &BTreeMap<String, u16>,
145 slot_id: u32,
146 compose_project_name: Option<&str>,
147) -> std::io::Result<()> {
148 for rel_path in files_to_copy {
149 let src = repo_root.join(rel_path);
150 let dest = worktree_path.join(rel_path);
151
152 if !src.exists() {
153 continue;
154 }
155
156 if src.is_dir() {
157 copy_dir_all(&src, &dest)?;
158 continue;
159 }
160
161 if let Some(parent) = dest.parent() {
162 fs::create_dir_all(parent)?;
163 }
164
165 let is_env_file = rel_path.ends_with(".env") || rel_path.contains(".env");
166 if is_env_file {
167 let content = fs::read_to_string(&src)?;
168 let updated =
169 update_env_content_with_ports(&content, raw_ports, slot_id, compose_project_name);
170 fs::write(&dest, updated)?;
171 } else {
172 fs::copy(&src, &dest)?;
173 }
174 }
175
176 Ok(())
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn test_sanitize_compose_project_name() {
185 assert_eq!(
186 sanitize_compose_project_name("git-claw", "user-auth", 2),
187 "git_claw_user_auth_2"
188 );
189 assert_eq!(
190 sanitize_compose_project_name("Repo.V1", "feat#1", 1),
191 "repo_v1_feat_1_1"
192 );
193 }
194
195 #[test]
196 fn test_update_env_content_with_ports() {
197 let original = r#"
198# Comments
199SECRET=xyz
200APP_PORT=80
201OTHER=abc
202"#;
203 let mut ports = BTreeMap::new();
204 ports.insert("APP_PORT".to_string(), 80);
205 ports.insert("EXTRA_PORT".to_string(), 9000);
206
207 let updated = update_env_content_with_ports(original, &ports, 2, Some("my_proj_1"));
208 assert!(updated.contains("APP_PORT=82"));
209 assert!(updated.contains("SECRET=xyz"));
210 assert!(updated.contains("EXTRA_PORT=9002"));
211 assert!(updated.contains("COMPOSE_PROJECT_NAME=my_proj_1"));
212 assert!(updated.contains("# Comments"));
213 }
214}