Skip to main content

git_claw/infra/
env_file.rs

1//! `.env.worktree` generation and environment variable injection.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::fs;
5use std::path::Path;
6
7/// Sanitizes a string to lowercase alphanumerics and underscores for Docker Compose project names.
8pub 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
21/// Generates `.env.worktree` inside the worktree directory.
22pub 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
48/// Updates port variables in-place within an environment file content string,
49/// and appends any missing port variables at the end.
50pub 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    // Append any unmatched declared ports
99    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    // Append COMPOSE_PROJECT_NAME if not already matched
108    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
121/// Recursively copies a directory and its contents from `src` to `dst`.
122pub 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
137/// Copies declared untracked files and directories from `repo_root` to `worktree_path`,
138/// creating parent directories, and updating matched port variables and COMPOSE_PROJECT_NAME
139/// in `.env` files in-place.
140pub 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}