Skip to main content

git_claw/infra/
env_file.rs

1//! `.env.worktree` generation and environment variable injection.
2
3use crate::core::config::EnvironmentConfig;
4use std::collections::{BTreeMap, BTreeSet};
5use std::fs;
6use std::path::Path;
7
8/// Sanitizes a string to lowercase alphanumerics and underscores for Docker Compose project names.
9pub 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
22/// Generates `.env.worktree` inside the worktree directory.
23///
24/// Injects slot ID, worktree name and path, Docker Compose project name,
25/// calculated dynamic ports, optional Python runtime environment (`PYENV_VERSION`),
26/// and virtualenv variables (`VIRTUAL_ENV`, `PATH`) when `.venv` is present.
27///
28/// # Examples
29///
30/// ```
31/// use git_claw::infra::env_file::write_env_worktree;
32/// use git_claw::core::config::EnvironmentConfig;
33/// use std::collections::BTreeMap;
34/// use tempfile::tempdir;
35/// use std::fs;
36///
37/// let dir = tempdir().unwrap();
38/// let wt = dir.path().join("wt-1");
39/// fs::create_dir_all(&wt).unwrap();
40///
41/// let env = EnvironmentConfig {
42///     name: Some("3.12.0".to_string()),
43///     ..Default::default()
44/// };
45/// write_env_worktree(&wt, "repo", "wt-1", 1, &BTreeMap::new(), Some(&env)).unwrap();
46/// let content = fs::read_to_string(wt.join(".env.worktree")).unwrap();
47/// assert!(content.contains("PYENV_VERSION=3.12.0"));
48/// ```
49pub 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
88/// Updates port variables in-place within an environment file content string,
89/// and appends any missing port variables at the end.
90pub 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    // Append any unmatched declared ports
139    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    // Append COMPOSE_PROJECT_NAME if not already matched
148    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
161/// Recursively copies a directory and its contents from `src` to `dst`.
162pub 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
177/// Copies declared untracked files and directories from `repo_root` to `worktree_path`,
178/// creating parent directories, and updating matched port variables and COMPOSE_PROJECT_NAME
179/// in `.env` files in-place.
180pub 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}