Skip to main content

cuenv_editorconfig/
lib.rs

1//! Generate .editorconfig files from CUE configuration.
2//!
3//! This crate provides a builder-based API for generating `.editorconfig` files
4//! from a declarative configuration.
5//!
6//! # Example
7//!
8//! ```rust,no_run
9//! use cuenv_editorconfig::{EditorConfigFile, EditorConfigSection};
10//!
11//! let result = EditorConfigFile::builder()
12//!     .directory(".")
13//!     .is_root(true)
14//!     .section("*", EditorConfigSection::new()
15//!         .indent_style("space")
16//!         .indent_size(4)
17//!         .end_of_line("lf"))
18//!     .section("*.md", EditorConfigSection::new()
19//!         .trim_trailing_whitespace(false))
20//!     .generate()?;
21//!
22//! println!("Status: {}", result.status);
23//! # Ok::<(), cuenv_editorconfig::Error>(())
24//! ```
25//!
26//! # Features
27//!
28//! - `serde`: Enable serde serialization/deserialization for configuration types
29
30#![warn(missing_docs)]
31
32use std::path::{Path, PathBuf};
33
34#[cfg(feature = "serde")]
35use serde::{Deserialize, Serialize};
36
37/// A section in an EditorConfig file.
38///
39/// Each section corresponds to a glob pattern and contains settings for files
40/// matching that pattern.
41///
42/// # Example
43///
44/// ```rust
45/// use cuenv_editorconfig::EditorConfigSection;
46///
47/// let section = EditorConfigSection::new()
48///     .indent_style("space")
49///     .indent_size(4)
50///     .end_of_line("lf")
51///     .charset("utf-8")
52///     .insert_final_newline(true)
53///     .trim_trailing_whitespace(true);
54/// ```
55#[derive(Debug, Clone, Default, PartialEq, Eq)]
56#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
57pub struct EditorConfigSection {
58    indent_style: Option<String>,
59    indent_size: Option<EditorConfigValue>,
60    tab_width: Option<u32>,
61    end_of_line: Option<String>,
62    charset: Option<String>,
63    trim_trailing_whitespace: Option<bool>,
64    insert_final_newline: Option<bool>,
65    max_line_length: Option<EditorConfigValue>,
66}
67
68/// A value that can be either an integer or a special string value.
69#[derive(Debug, Clone, PartialEq, Eq)]
70#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
71#[cfg_attr(feature = "serde", serde(untagged))]
72pub enum EditorConfigValue {
73    /// Integer value
74    Int(u32),
75    /// String value (e.g., "tab" for indent_size, "off" for max_line_length)
76    String(String),
77}
78
79impl EditorConfigValue {
80    fn to_editorconfig_value(&self) -> String {
81        match self {
82            Self::Int(n) => n.to_string(),
83            Self::String(s) => s.clone(),
84        }
85    }
86}
87
88impl EditorConfigSection {
89    /// Create a new empty section.
90    #[must_use]
91    pub fn new() -> Self {
92        Self::default()
93    }
94
95    /// Set the indentation style.
96    ///
97    /// Valid values: "tab", "space"
98    #[must_use]
99    pub fn indent_style(mut self, style: impl Into<String>) -> Self {
100        self.indent_style = Some(style.into());
101        self
102    }
103
104    /// Set the indentation style from an optional value.
105    #[must_use]
106    pub fn indent_style_opt(mut self, style: Option<impl Into<String>>) -> Self {
107        self.indent_style = style.map(Into::into);
108        self
109    }
110
111    /// Set the indentation size.
112    #[must_use]
113    pub fn indent_size(mut self, size: u32) -> Self {
114        self.indent_size = Some(EditorConfigValue::Int(size));
115        self
116    }
117
118    /// Set the indentation size to "tab".
119    #[must_use]
120    pub fn indent_size_tab(mut self) -> Self {
121        self.indent_size = Some(EditorConfigValue::String("tab".to_string()));
122        self
123    }
124
125    /// Set the indentation size from an optional value.
126    #[must_use]
127    pub fn indent_size_opt(mut self, size: Option<EditorConfigValue>) -> Self {
128        self.indent_size = size;
129        self
130    }
131
132    /// Set the tab width.
133    #[must_use]
134    pub fn tab_width(mut self, width: u32) -> Self {
135        self.tab_width = Some(width);
136        self
137    }
138
139    /// Set the tab width from an optional value.
140    #[must_use]
141    pub fn tab_width_opt(mut self, width: Option<u32>) -> Self {
142        self.tab_width = width;
143        self
144    }
145
146    /// Set the line ending style.
147    ///
148    /// Valid values: "lf", "crlf", "cr"
149    #[must_use]
150    pub fn end_of_line(mut self, eol: impl Into<String>) -> Self {
151        self.end_of_line = Some(eol.into());
152        self
153    }
154
155    /// Set the line ending style from an optional value.
156    #[must_use]
157    pub fn end_of_line_opt(mut self, eol: Option<impl Into<String>>) -> Self {
158        self.end_of_line = eol.map(Into::into);
159        self
160    }
161
162    /// Set the character encoding.
163    ///
164    /// Valid values: "utf-8", "utf-8-bom", "utf-16be", "utf-16le", "latin1"
165    #[must_use]
166    pub fn charset(mut self, charset: impl Into<String>) -> Self {
167        self.charset = Some(charset.into());
168        self
169    }
170
171    /// Set the character encoding from an optional value.
172    #[must_use]
173    pub fn charset_opt(mut self, charset: Option<impl Into<String>>) -> Self {
174        self.charset = charset.map(Into::into);
175        self
176    }
177
178    /// Set whether to trim trailing whitespace.
179    #[must_use]
180    pub fn trim_trailing_whitespace(mut self, trim: bool) -> Self {
181        self.trim_trailing_whitespace = Some(trim);
182        self
183    }
184
185    /// Set whether to trim trailing whitespace from an optional value.
186    #[must_use]
187    pub fn trim_trailing_whitespace_opt(mut self, trim: Option<bool>) -> Self {
188        self.trim_trailing_whitespace = trim;
189        self
190    }
191
192    /// Set whether to insert a final newline.
193    #[must_use]
194    pub fn insert_final_newline(mut self, insert: bool) -> Self {
195        self.insert_final_newline = Some(insert);
196        self
197    }
198
199    /// Set whether to insert a final newline from an optional value.
200    #[must_use]
201    pub fn insert_final_newline_opt(mut self, insert: Option<bool>) -> Self {
202        self.insert_final_newline = insert;
203        self
204    }
205
206    /// Set the maximum line length.
207    #[must_use]
208    pub fn max_line_length(mut self, length: u32) -> Self {
209        self.max_line_length = Some(EditorConfigValue::Int(length));
210        self
211    }
212
213    /// Set the maximum line length to "off".
214    #[must_use]
215    pub fn max_line_length_off(mut self) -> Self {
216        self.max_line_length = Some(EditorConfigValue::String("off".to_string()));
217        self
218    }
219
220    /// Set the maximum line length from an optional value.
221    #[must_use]
222    pub fn max_line_length_opt(mut self, length: Option<EditorConfigValue>) -> Self {
223        self.max_line_length = length;
224        self
225    }
226
227    /// Check if this section has any settings.
228    #[must_use]
229    pub fn is_empty(&self) -> bool {
230        self.indent_style.is_none()
231            && self.indent_size.is_none()
232            && self.tab_width.is_none()
233            && self.end_of_line.is_none()
234            && self.charset.is_none()
235            && self.trim_trailing_whitespace.is_none()
236            && self.insert_final_newline.is_none()
237            && self.max_line_length.is_none()
238    }
239
240    /// Generate the content for this section (without the header).
241    fn generate_content(&self) -> Vec<String> {
242        let mut lines = Vec::new();
243
244        if let Some(ref style) = self.indent_style {
245            lines.push(format!("indent_style = {style}"));
246        }
247        if let Some(ref size) = self.indent_size {
248            lines.push(format!("indent_size = {}", size.to_editorconfig_value()));
249        }
250        if let Some(width) = self.tab_width {
251            lines.push(format!("tab_width = {width}"));
252        }
253        if let Some(ref eol) = self.end_of_line {
254            lines.push(format!("end_of_line = {eol}"));
255        }
256        if let Some(ref charset) = self.charset {
257            lines.push(format!("charset = {charset}"));
258        }
259        if let Some(trim) = self.trim_trailing_whitespace {
260            lines.push(format!("trim_trailing_whitespace = {trim}"));
261        }
262        if let Some(insert) = self.insert_final_newline {
263            lines.push(format!("insert_final_newline = {insert}"));
264        }
265        if let Some(ref length) = self.max_line_length {
266            lines.push(format!(
267                "max_line_length = {}",
268                length.to_editorconfig_value()
269            ));
270        }
271
272        lines
273    }
274}
275
276/// Builder for generating an EditorConfig file.
277///
278/// # Example
279///
280/// ```rust,no_run
281/// use cuenv_editorconfig::{EditorConfigFile, EditorConfigSection};
282///
283/// let result = EditorConfigFile::builder()
284///     .directory("/path/to/project")
285///     .is_root(true)
286///     .section("*", EditorConfigSection::new()
287///         .indent_style("space")
288///         .indent_size(4))
289///     .generate()?;
290/// # Ok::<(), cuenv_editorconfig::Error>(())
291/// ```
292#[derive(Debug, Default)]
293pub struct EditorConfigFileBuilder {
294    directory: Option<PathBuf>,
295    is_root: bool,
296    sections: Vec<(String, EditorConfigSection)>,
297    dry_run: bool,
298    header: Option<String>,
299}
300
301/// Entry point for building and generating EditorConfig files.
302pub struct EditorConfigFile;
303
304impl EditorConfigFile {
305    /// Create a new builder for generating an EditorConfig file.
306    #[must_use]
307    pub fn builder() -> EditorConfigFileBuilder {
308        EditorConfigFileBuilder::default()
309    }
310}
311
312impl EditorConfigFileBuilder {
313    /// Set the directory where the .editorconfig file will be generated.
314    ///
315    /// Defaults to the current directory if not set.
316    #[must_use]
317    pub fn directory(mut self, dir: impl AsRef<Path>) -> Self {
318        self.directory = Some(dir.as_ref().to_path_buf());
319        self
320    }
321
322    /// Set whether this is the root .editorconfig file.
323    ///
324    /// When true, adds `root = true` at the top of the file, which tells
325    /// editors to stop searching for .editorconfig files in parent directories.
326    #[must_use]
327    pub const fn is_root(mut self, is_root: bool) -> Self {
328        self.is_root = is_root;
329        self
330    }
331
332    /// Add a section to the EditorConfig file.
333    ///
334    /// Sections are output in the order they are added.
335    #[must_use]
336    pub fn section(mut self, pattern: impl Into<String>, section: EditorConfigSection) -> Self {
337        self.sections.push((pattern.into(), section));
338        self
339    }
340
341    /// Add multiple sections to the EditorConfig file.
342    #[must_use]
343    pub fn sections(
344        mut self,
345        sections: impl IntoIterator<Item = (impl Into<String>, EditorConfigSection)>,
346    ) -> Self {
347        self.sections
348            .extend(sections.into_iter().map(|(p, s)| (p.into(), s)));
349        self
350    }
351
352    /// Enable dry-run mode.
353    ///
354    /// When true, no files will be written. The result will indicate
355    /// what would happen with `WouldCreate` and `WouldUpdate` statuses.
356    #[must_use]
357    pub const fn dry_run(mut self, dry_run: bool) -> Self {
358        self.dry_run = dry_run;
359        self
360    }
361
362    /// Set a header comment for the file.
363    ///
364    /// The header will be added at the top of the file with `#` prefixes.
365    #[must_use]
366    pub fn header(mut self, header: impl Into<String>) -> Self {
367        self.header = Some(header.into());
368        self
369    }
370
371    /// Generate the EditorConfig file.
372    ///
373    /// # Errors
374    ///
375    /// Returns an error if file I/O fails.
376    pub fn generate(self) -> Result<SyncResult> {
377        let dir = self.directory.clone().unwrap_or_else(|| PathBuf::from("."));
378        let filepath = dir.join(".editorconfig");
379
380        tracing::info!(
381            path = %filepath.display(),
382            is_root = self.is_root,
383            sections = self.sections.len(),
384            "Generating .editorconfig"
385        );
386
387        let content = self.generate_content();
388
389        let status = if self.dry_run {
390            determine_dry_run_status(&filepath, &content)?
391        } else {
392            write_file(&filepath, &content)?
393        };
394
395        tracing::info!(
396            status = %status,
397            "Processed .editorconfig"
398        );
399
400        Ok(SyncResult { status })
401    }
402
403    /// Generate the file content as a string.
404    #[must_use]
405    pub fn generate_content(&self) -> String {
406        let mut lines = Vec::new();
407
408        // Add header comment if present
409        if let Some(ref header) = self.header {
410            for line in header.lines() {
411                lines.push(format!("# {line}"));
412            }
413            lines.push(String::new());
414        }
415
416        // Add root directive if this is the root file
417        if self.is_root {
418            lines.push("root = true".to_string());
419            lines.push(String::new());
420        }
421
422        // Add sections
423        for (pattern, section) in &self.sections {
424            if section.is_empty() {
425                continue;
426            }
427
428            lines.push(format!("[{pattern}]"));
429            lines.extend(section.generate_content());
430            lines.push(String::new());
431        }
432
433        // Remove trailing empty line and add final newline
434        while lines.last().is_some_and(String::is_empty) {
435            lines.pop();
436        }
437
438        if lines.is_empty() {
439            String::new()
440        } else {
441            format!("{}\n", lines.join("\n"))
442        }
443    }
444}
445
446// ============================================================================
447// Result types
448// ============================================================================
449
450/// Result of generating an EditorConfig file.
451#[derive(Debug)]
452pub struct SyncResult {
453    /// The status of the file operation.
454    pub status: FileStatus,
455}
456
457/// Status of a file operation.
458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
459pub enum FileStatus {
460    /// File was newly created.
461    Created,
462    /// File existed and was updated with new content.
463    Updated,
464    /// File existed and content was unchanged.
465    Unchanged,
466    /// Would be created (dry-run mode).
467    WouldCreate,
468    /// Would be updated (dry-run mode).
469    WouldUpdate,
470}
471
472impl std::fmt::Display for FileStatus {
473    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
474        match self {
475            Self::Created => write!(f, "Created"),
476            Self::Updated => write!(f, "Updated"),
477            Self::Unchanged => write!(f, "Unchanged"),
478            Self::WouldCreate => write!(f, "Would create"),
479            Self::WouldUpdate => write!(f, "Would update"),
480        }
481    }
482}
483
484// ============================================================================
485// Error types
486// ============================================================================
487
488/// Errors that can occur during EditorConfig file generation.
489#[derive(Debug, thiserror::Error)]
490pub enum Error {
491    /// IO error during file operations.
492    #[error("IO error: {0}")]
493    Io(#[from] std::io::Error),
494}
495
496/// Result type for EditorConfig operations.
497pub type Result<T> = std::result::Result<T, Error>;
498
499// ============================================================================
500// Internal helpers
501// ============================================================================
502
503/// Determine what would happen to a file in dry-run mode.
504fn determine_dry_run_status(filepath: &Path, content: &str) -> Result<FileStatus> {
505    if !filepath.exists() {
506        return Ok(FileStatus::WouldCreate);
507    }
508    let existing = std::fs::read_to_string(filepath)?;
509    Ok(if existing == content {
510        FileStatus::Unchanged
511    } else {
512        FileStatus::WouldUpdate
513    })
514}
515
516/// Write a file and return the status.
517fn write_file(filepath: &Path, content: &str) -> Result<FileStatus> {
518    let status = if filepath.exists() {
519        let existing = std::fs::read_to_string(filepath)?;
520        if existing == content {
521            return Ok(FileStatus::Unchanged);
522        }
523        FileStatus::Updated
524    } else {
525        FileStatus::Created
526    };
527
528    std::fs::write(filepath, content)?;
529    Ok(status)
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535
536    #[test]
537    fn test_section_new() {
538        let section = EditorConfigSection::new();
539        assert!(section.is_empty());
540    }
541
542    #[test]
543    fn test_section_builder() {
544        let section = EditorConfigSection::new()
545            .indent_style("space")
546            .indent_size(4)
547            .end_of_line("lf");
548
549        assert!(!section.is_empty());
550        let content = section.generate_content();
551        assert!(content.contains(&"indent_style = space".to_string()));
552        assert!(content.contains(&"indent_size = 4".to_string()));
553        assert!(content.contains(&"end_of_line = lf".to_string()));
554    }
555
556    #[test]
557    fn test_section_all_options() {
558        let section = EditorConfigSection::new()
559            .indent_style("tab")
560            .indent_size(2)
561            .tab_width(4)
562            .end_of_line("crlf")
563            .charset("utf-8")
564            .trim_trailing_whitespace(true)
565            .insert_final_newline(true)
566            .max_line_length(120);
567
568        let content = section.generate_content();
569        assert_eq!(content.len(), 8);
570    }
571
572    #[test]
573    fn test_section_tab_indent_size() {
574        let section = EditorConfigSection::new().indent_size_tab();
575        let content = section.generate_content();
576        assert!(content.contains(&"indent_size = tab".to_string()));
577    }
578
579    #[test]
580    fn test_section_max_line_length_off() {
581        let section = EditorConfigSection::new().max_line_length_off();
582        let content = section.generate_content();
583        assert!(content.contains(&"max_line_length = off".to_string()));
584    }
585
586    #[test]
587    fn test_builder_generate_content_root() {
588        let content = EditorConfigFile::builder()
589            .is_root(true)
590            .section(
591                "*",
592                EditorConfigSection::new()
593                    .indent_style("space")
594                    .indent_size(4),
595            )
596            .generate_content();
597
598        assert!(content.starts_with("root = true\n"));
599        assert!(content.contains("[*]"));
600        assert!(content.contains("indent_style = space"));
601        assert!(content.contains("indent_size = 4"));
602    }
603
604    #[test]
605    fn test_builder_generate_content_with_header() {
606        let content = EditorConfigFile::builder()
607            .header("Generated by cuenv")
608            .section("*", EditorConfigSection::new().indent_style("space"))
609            .generate_content();
610
611        assert!(content.starts_with("# Generated by cuenv\n"));
612    }
613
614    #[test]
615    fn test_builder_multiple_sections() {
616        let content = EditorConfigFile::builder()
617            .is_root(true)
618            .section("*", EditorConfigSection::new().indent_style("space"))
619            .section(
620                "*.md",
621                EditorConfigSection::new().trim_trailing_whitespace(false),
622            )
623            .section("Makefile", EditorConfigSection::new().indent_style("tab"))
624            .generate_content();
625
626        assert!(content.contains("[*]"));
627        assert!(content.contains("[*.md]"));
628        assert!(content.contains("[Makefile]"));
629    }
630
631    #[test]
632    fn test_builder_empty_sections_skipped() {
633        let content = EditorConfigFile::builder()
634            .section("*", EditorConfigSection::new().indent_style("space"))
635            .section("*.txt", EditorConfigSection::new()) // Empty, should be skipped
636            .generate_content();
637
638        assert!(content.contains("[*]"));
639        assert!(!content.contains("[*.txt]"));
640    }
641
642    #[test]
643    fn test_file_status_display() {
644        assert_eq!(FileStatus::Created.to_string(), "Created");
645        assert_eq!(FileStatus::Updated.to_string(), "Updated");
646        assert_eq!(FileStatus::Unchanged.to_string(), "Unchanged");
647        assert_eq!(FileStatus::WouldCreate.to_string(), "Would create");
648        assert_eq!(FileStatus::WouldUpdate.to_string(), "Would update");
649    }
650
651    #[test]
652    fn test_section_optional_builders_none() {
653        let section = EditorConfigSection::new()
654            .indent_style_opt(None::<String>)
655            .indent_size_opt(None)
656            .tab_width_opt(None)
657            .end_of_line_opt(None::<String>)
658            .charset_opt(None::<String>)
659            .trim_trailing_whitespace_opt(None)
660            .insert_final_newline_opt(None)
661            .max_line_length_opt(None);
662
663        assert!(section.is_empty());
664    }
665
666    #[test]
667    fn test_section_optional_builders_some() {
668        let section = EditorConfigSection::new()
669            .indent_style_opt(Some("space"))
670            .indent_size_opt(Some(EditorConfigValue::Int(4)))
671            .tab_width_opt(Some(4))
672            .end_of_line_opt(Some("lf"))
673            .charset_opt(Some("utf-8"))
674            .trim_trailing_whitespace_opt(Some(true))
675            .insert_final_newline_opt(Some(true))
676            .max_line_length_opt(Some(EditorConfigValue::Int(120)));
677
678        let content = section.generate_content();
679        assert_eq!(content.len(), 8);
680    }
681
682    #[test]
683    fn test_editor_config_value_int() {
684        let val = EditorConfigValue::Int(42);
685        assert_eq!(val.to_editorconfig_value(), "42");
686    }
687
688    #[test]
689    fn test_editor_config_value_string() {
690        let val = EditorConfigValue::String("tab".to_string());
691        assert_eq!(val.to_editorconfig_value(), "tab");
692    }
693
694    #[test]
695    fn test_section_equality() {
696        let s1 = EditorConfigSection::new()
697            .indent_style("space")
698            .indent_size(4);
699        let s2 = EditorConfigSection::new()
700            .indent_style("space")
701            .indent_size(4);
702        let s3 = EditorConfigSection::new()
703            .indent_style("tab")
704            .indent_size(4);
705
706        assert_eq!(s1, s2);
707        assert_ne!(s1, s3);
708    }
709
710    #[test]
711    fn test_section_clone() {
712        let original = EditorConfigSection::new()
713            .indent_style("space")
714            .charset("utf-8");
715        let cloned = original.clone();
716        assert_eq!(original, cloned);
717    }
718
719    #[test]
720    fn test_section_debug() {
721        let section = EditorConfigSection::new().indent_style("space");
722        let debug = format!("{section:?}");
723        assert!(debug.contains("EditorConfigSection"));
724        assert!(debug.contains("space"));
725    }
726
727    #[test]
728    fn test_builder_sections_method() {
729        let sections = vec![
730            ("*", EditorConfigSection::new().indent_style("space")),
731            (
732                "*.md",
733                EditorConfigSection::new().trim_trailing_whitespace(false),
734            ),
735        ];
736
737        let content = EditorConfigFile::builder()
738            .sections(sections)
739            .generate_content();
740
741        assert!(content.contains("[*]"));
742        assert!(content.contains("[*.md]"));
743    }
744
745    #[test]
746    fn test_builder_empty_content() {
747        let content = EditorConfigFile::builder().generate_content();
748        assert!(content.is_empty());
749    }
750
751    #[test]
752    fn test_builder_only_root() {
753        let content = EditorConfigFile::builder().is_root(true).generate_content();
754        assert_eq!(content, "root = true\n");
755    }
756
757    #[test]
758    fn test_builder_directory() {
759        let builder = EditorConfigFile::builder().directory("/tmp/test");
760        let debug = format!("{builder:?}");
761        assert!(debug.contains("/tmp/test"));
762    }
763
764    #[test]
765    fn test_builder_dry_run() {
766        let builder = EditorConfigFile::builder().dry_run(true);
767        let debug = format!("{builder:?}");
768        assert!(debug.contains("dry_run: true"));
769    }
770
771    #[test]
772    fn test_multiline_header() {
773        let content = EditorConfigFile::builder()
774            .header("Line 1\nLine 2\nLine 3")
775            .section("*", EditorConfigSection::new().indent_style("space"))
776            .generate_content();
777
778        assert!(content.contains("# Line 1\n"));
779        assert!(content.contains("# Line 2\n"));
780        assert!(content.contains("# Line 3\n"));
781    }
782
783    #[test]
784    fn test_file_status_equality() {
785        assert_eq!(FileStatus::Created, FileStatus::Created);
786        assert_ne!(FileStatus::Created, FileStatus::Updated);
787    }
788
789    #[test]
790    fn test_file_status_clone() {
791        let status = FileStatus::Created;
792        let cloned = status;
793        assert_eq!(status, cloned);
794    }
795
796    #[test]
797    fn test_file_status_debug() {
798        let debug = format!("{:?}", FileStatus::WouldCreate);
799        assert!(debug.contains("WouldCreate"));
800    }
801
802    #[test]
803    fn test_sync_result_debug() {
804        let result = SyncResult {
805            status: FileStatus::Created,
806        };
807        let debug = format!("{result:?}");
808        assert!(debug.contains("SyncResult"));
809        assert!(debug.contains("Created"));
810    }
811
812    #[test]
813    fn test_error_io() {
814        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
815        let err: Error = io_err.into();
816        let msg = err.to_string();
817        assert!(msg.contains("IO error"));
818    }
819
820    #[test]
821    fn test_error_debug() {
822        let io_err = std::io::Error::other("test error");
823        let err: Error = io_err.into();
824        let debug = format!("{err:?}");
825        assert!(debug.contains("Io"));
826    }
827
828    #[test]
829    fn test_generate_with_dry_run_would_create() {
830        let temp = tempfile::TempDir::new().unwrap();
831        let result = EditorConfigFile::builder()
832            .directory(temp.path())
833            .is_root(true)
834            .section("*", EditorConfigSection::new().indent_style("space"))
835            .dry_run(true)
836            .generate()
837            .unwrap();
838
839        assert_eq!(result.status, FileStatus::WouldCreate);
840        // File should not exist
841        assert!(!temp.path().join(".editorconfig").exists());
842    }
843
844    #[test]
845    fn test_generate_creates_file() {
846        let temp = tempfile::TempDir::new().unwrap();
847        let result = EditorConfigFile::builder()
848            .directory(temp.path())
849            .is_root(true)
850            .section("*", EditorConfigSection::new().indent_style("space"))
851            .generate()
852            .unwrap();
853
854        assert_eq!(result.status, FileStatus::Created);
855        assert!(temp.path().join(".editorconfig").exists());
856    }
857
858    #[test]
859    fn test_generate_unchanged() {
860        let temp = tempfile::TempDir::new().unwrap();
861
862        // First write
863        let _ = EditorConfigFile::builder()
864            .directory(temp.path())
865            .is_root(true)
866            .section("*", EditorConfigSection::new().indent_style("space"))
867            .generate()
868            .unwrap();
869
870        // Second write with same content
871        let result = EditorConfigFile::builder()
872            .directory(temp.path())
873            .is_root(true)
874            .section("*", EditorConfigSection::new().indent_style("space"))
875            .generate()
876            .unwrap();
877
878        assert_eq!(result.status, FileStatus::Unchanged);
879    }
880
881    #[test]
882    fn test_generate_updated() {
883        let temp = tempfile::TempDir::new().unwrap();
884
885        // First write
886        let _ = EditorConfigFile::builder()
887            .directory(temp.path())
888            .is_root(true)
889            .section("*", EditorConfigSection::new().indent_style("space"))
890            .generate()
891            .unwrap();
892
893        // Second write with different content
894        let result = EditorConfigFile::builder()
895            .directory(temp.path())
896            .is_root(true)
897            .section("*", EditorConfigSection::new().indent_style("tab"))
898            .generate()
899            .unwrap();
900
901        assert_eq!(result.status, FileStatus::Updated);
902    }
903}