1#![warn(missing_docs)]
31
32use std::path::{Path, PathBuf};
33
34#[cfg(feature = "serde")]
35use serde::{Deserialize, Serialize};
36
37#[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#[derive(Debug, Clone, PartialEq, Eq)]
70#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
71#[cfg_attr(feature = "serde", serde(untagged))]
72pub enum EditorConfigValue {
73 Int(u32),
75 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 #[must_use]
91 pub fn new() -> Self {
92 Self::default()
93 }
94
95 #[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 #[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 #[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 #[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 #[must_use]
127 pub fn indent_size_opt(mut self, size: Option<EditorConfigValue>) -> Self {
128 self.indent_size = size;
129 self
130 }
131
132 #[must_use]
134 pub fn tab_width(mut self, width: u32) -> Self {
135 self.tab_width = Some(width);
136 self
137 }
138
139 #[must_use]
141 pub fn tab_width_opt(mut self, width: Option<u32>) -> Self {
142 self.tab_width = width;
143 self
144 }
145
146 #[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 #[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 #[must_use]
166 pub fn charset(mut self, charset: impl Into<String>) -> Self {
167 self.charset = Some(charset.into());
168 self
169 }
170
171 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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#[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
301pub struct EditorConfigFile;
303
304impl EditorConfigFile {
305 #[must_use]
307 pub fn builder() -> EditorConfigFileBuilder {
308 EditorConfigFileBuilder::default()
309 }
310}
311
312impl EditorConfigFileBuilder {
313 #[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 #[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 #[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 #[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 #[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 #[must_use]
366 pub fn header(mut self, header: impl Into<String>) -> Self {
367 self.header = Some(header.into());
368 self
369 }
370
371 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 #[must_use]
405 pub fn generate_content(&self) -> String {
406 let mut lines = Vec::new();
407
408 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 if self.is_root {
418 lines.push("root = true".to_string());
419 lines.push(String::new());
420 }
421
422 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 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#[derive(Debug)]
452pub struct SyncResult {
453 pub status: FileStatus,
455}
456
457#[derive(Debug, Clone, Copy, PartialEq, Eq)]
459pub enum FileStatus {
460 Created,
462 Updated,
464 Unchanged,
466 WouldCreate,
468 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#[derive(Debug, thiserror::Error)]
490pub enum Error {
491 #[error("IO error: {0}")]
493 Io(#[from] std::io::Error),
494}
495
496pub type Result<T> = std::result::Result<T, Error>;
498
499fn 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
516fn 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()) .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 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 let _ = EditorConfigFile::builder()
864 .directory(temp.path())
865 .is_root(true)
866 .section("*", EditorConfigSection::new().indent_style("space"))
867 .generate()
868 .unwrap();
869
870 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 let _ = EditorConfigFile::builder()
887 .directory(temp.path())
888 .is_root(true)
889 .section("*", EditorConfigSection::new().indent_style("space"))
890 .generate()
891 .unwrap();
892
893 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}