1use std::path::Path;
24
25#[derive(Debug, Clone)]
27pub struct IgnoreConfig {
28 pub tool: String,
31 pub patterns: Vec<String>,
33 pub filename: Option<String>,
35}
36
37#[derive(Debug)]
39pub struct SyncResult {
40 pub files: Vec<FileResult>,
42}
43
44#[derive(Debug)]
46pub struct FileResult {
47 pub filename: String,
49 pub status: FileStatus,
51 pub pattern_count: usize,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum FileStatus {
58 Created,
60 Updated,
62 Unchanged,
64 WouldCreate,
66 WouldUpdate,
68}
69
70impl std::fmt::Display for FileStatus {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 match self {
73 Self::Created => write!(f, "Created"),
74 Self::Updated => write!(f, "Updated"),
75 Self::Unchanged => write!(f, "Unchanged"),
76 Self::WouldCreate => write!(f, "Would create"),
77 Self::WouldUpdate => write!(f, "Would update"),
78 }
79 }
80}
81
82#[derive(Debug, thiserror::Error)]
84pub enum Error {
85 #[error("Invalid tool name '{name}': {reason}")]
87 InvalidToolName {
88 name: String,
90 reason: String,
92 },
93
94 #[error("cuenv sync ignore must be run within a Git repository")]
96 NotInGitRepo,
97
98 #[error("Cannot sync in a bare Git repository")]
100 BareRepository,
101
102 #[error("Target directory must be within the Git repository")]
104 OutsideGitRepo,
105
106 #[error("IO error: {0}")]
108 Io(#[from] std::io::Error),
109}
110
111pub type Result<T> = std::result::Result<T, Error>;
113
114pub fn generate_ignore_files(
150 dir: &Path,
151 configs: Vec<IgnoreConfig>,
152 dry_run: bool,
153) -> Result<SyncResult> {
154 tracing::info!("Starting ignore file generation");
155
156 verify_git_repository(dir)?;
158
159 let mut results = Vec::new();
160
161 let mut sorted_configs = configs;
163 sorted_configs.sort_by(|a, b| a.tool.cmp(&b.tool));
164
165 for config in sorted_configs {
166 if config.patterns.is_empty() {
168 tracing::debug!("Skipping tool '{}' - no patterns", config.tool);
169 continue;
170 }
171
172 validate_tool_name(&config.tool)?;
174
175 let filename = get_ignore_filename(&config.tool, config.filename.as_deref());
177
178 validate_filename(&filename)?;
180
181 let filepath = dir.join(&filename);
182 let content = generate_ignore_content(&config.patterns);
183
184 let (status, pattern_count) = if dry_run {
185 let status = if filepath.exists() {
186 let existing = std::fs::read_to_string(&filepath)?;
187 if existing == content {
188 FileStatus::Unchanged
189 } else {
190 FileStatus::WouldUpdate
191 }
192 } else {
193 FileStatus::WouldCreate
194 };
195 (status, config.patterns.len())
196 } else {
197 let status = write_ignore_file(&filepath, &content)?;
198 (status, config.patterns.len())
199 };
200
201 tracing::info!(
202 filename = %filename,
203 status = %status,
204 patterns = pattern_count,
205 "Processed ignore file"
206 );
207
208 results.push(FileResult {
209 filename,
210 status,
211 pattern_count,
212 });
213 }
214
215 Ok(SyncResult { files: results })
216}
217
218fn verify_git_repository(dir: &Path) -> Result<()> {
220 let repo = gix::discover(dir).map_err(|e| {
221 tracing::debug!("Git discovery failed: {}", e);
222 Error::NotInGitRepo
223 })?;
224
225 let git_root = repo.workdir().ok_or(Error::BareRepository)?;
226
227 let canonical_dir = std::fs::canonicalize(dir)?;
229 let canonical_git = std::fs::canonicalize(git_root)?;
230
231 if !canonical_dir.starts_with(&canonical_git) {
232 return Err(Error::OutsideGitRepo);
233 }
234
235 tracing::debug!(
236 git_root = %canonical_git.display(),
237 target_dir = %canonical_dir.display(),
238 "Verified directory is within Git repository"
239 );
240
241 Ok(())
242}
243
244fn validate_tool_name(tool: &str) -> Result<()> {
246 if tool.is_empty() {
247 return Err(Error::InvalidToolName {
248 name: tool.to_string(),
249 reason: "tool name cannot be empty".to_string(),
250 });
251 }
252
253 if tool.contains('/') || tool.contains('\\') {
254 return Err(Error::InvalidToolName {
255 name: tool.to_string(),
256 reason: "tool name cannot contain path separators".to_string(),
257 });
258 }
259
260 if tool.contains("..") {
261 return Err(Error::InvalidToolName {
262 name: tool.to_string(),
263 reason: "tool name cannot contain parent directory references".to_string(),
264 });
265 }
266
267 Ok(())
268}
269
270fn validate_filename(filename: &str) -> Result<()> {
272 if filename.contains('/') || filename.contains('\\') {
273 return Err(Error::InvalidToolName {
274 name: filename.to_string(),
275 reason: "filename cannot contain path separators".to_string(),
276 });
277 }
278
279 if filename.contains("..") {
280 return Err(Error::InvalidToolName {
281 name: filename.to_string(),
282 reason: "filename cannot contain parent directory references".to_string(),
283 });
284 }
285
286 Ok(())
287}
288
289fn get_ignore_filename(tool: &str, override_filename: Option<&str>) -> String {
291 override_filename.map_or_else(|| format!(".{tool}ignore"), String::from)
292}
293
294fn generate_ignore_content(patterns: &[String]) -> String {
296 let mut lines = vec![
297 "# Generated by cuenv - do not edit".to_string(),
298 "# Source: env.cue".to_string(),
299 String::new(),
300 ];
301 lines.extend(patterns.iter().cloned());
302 format!("{}\n", lines.join("\n"))
303}
304
305fn write_ignore_file(filepath: &Path, content: &str) -> Result<FileStatus> {
307 let status = if filepath.exists() {
308 let existing = std::fs::read_to_string(filepath)?;
309 if existing == content {
310 return Ok(FileStatus::Unchanged);
311 }
312 FileStatus::Updated
313 } else {
314 FileStatus::Created
315 };
316
317 std::fs::write(filepath, content)?;
318 Ok(status)
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324
325 #[test]
326 fn test_get_ignore_filename_default() {
327 assert_eq!(get_ignore_filename("git", None), ".gitignore");
328 assert_eq!(get_ignore_filename("docker", None), ".dockerignore");
329 assert_eq!(get_ignore_filename("npm", None), ".npmignore");
330 assert_eq!(get_ignore_filename("custom", None), ".customignore");
331 }
332
333 #[test]
334 fn test_get_ignore_filename_override() {
335 assert_eq!(
336 get_ignore_filename("git", Some(".my-gitignore")),
337 ".my-gitignore"
338 );
339 assert_eq!(get_ignore_filename("custom", Some(".special")), ".special");
340 }
341
342 #[test]
343 fn test_validate_tool_name_valid() {
344 assert!(validate_tool_name("git").is_ok());
345 assert!(validate_tool_name("docker").is_ok());
346 assert!(validate_tool_name("my-custom-tool").is_ok());
347 assert!(validate_tool_name("tool_with_underscore").is_ok());
348 }
349
350 #[test]
351 fn test_validate_tool_name_invalid() {
352 assert!(validate_tool_name("").is_err());
354
355 assert!(validate_tool_name("../etc").is_err());
357 assert!(validate_tool_name("foo/bar").is_err());
358 assert!(validate_tool_name("foo\\bar").is_err());
359
360 assert!(validate_tool_name("..").is_err());
362 assert!(validate_tool_name("foo..bar").is_err());
363 }
364
365 #[test]
366 fn test_generate_ignore_content() {
367 let patterns = vec![
368 "node_modules/".to_string(),
369 ".env".to_string(),
370 "*.log".to_string(),
371 ];
372 let content = generate_ignore_content(&patterns);
373
374 assert!(content.starts_with("# Generated by cuenv - do not edit"));
375 assert!(content.contains("# Source: env.cue"));
376 assert!(content.contains("node_modules/"));
377 assert!(content.contains(".env"));
378 assert!(content.contains("*.log"));
379 assert!(content.ends_with('\n'));
380 }
381
382 #[test]
383 fn test_generate_ignore_content_empty() {
384 let patterns: Vec<String> = vec![];
385 let content = generate_ignore_content(&patterns);
386
387 assert!(content.starts_with("# Generated by cuenv - do not edit"));
388 assert!(content.contains("# Source: env.cue"));
389 assert!(content.ends_with('\n'));
390 }
391
392 #[test]
393 fn test_file_status_display() {
394 assert_eq!(FileStatus::Created.to_string(), "Created");
395 assert_eq!(FileStatus::Updated.to_string(), "Updated");
396 assert_eq!(FileStatus::Unchanged.to_string(), "Unchanged");
397 assert_eq!(FileStatus::WouldCreate.to_string(), "Would create");
398 assert_eq!(FileStatus::WouldUpdate.to_string(), "Would update");
399 }
400}