Repository: dotenv-linter/dotenv-linter
Pull request #228 (2020-06-30 21:14:09)

Autofix
I have implemented automatic correction of the contents of files based on the results of the check.
When displaying, all warnings are marked as Fixed or Unfixed.
If all warnings are fixed, exit code is 0.

Just one kind of checks is fixable for now (LowercaseKey), but I have create the scaffold for implementing all kinds of checks.

The CLI key for the autofix mode is `-f`/`--fix`.

A few comments about the design:

The separate trait `Fix` was made for fixes (I tried to keep some symmetry with `checks`), but it is possible to expand the trait `Check` itself with this functions too.

There are two groups of fixes: some can be implemented by working only with the line that caused warning, for others you need to work with the entire line collection.

The `Fix` trait allows you to work with both groups of fixes due to default function implementations.

#### ✔ Checklist:

- [x] This PR has been added to [CHANGELOG.md](https://github.com/dotenv-linter/dotenv-linter/blob/master/CHANGELOG.md) (at the top of the list);
- [x] Tests for the changes have been added (for bug fixes / features);
- [x] Docs have been added / updated (for bug fixes / features).


mgrachev: @evgeniy-r Thank you for your contribution! 🚀

This PR has a lot of changes. I guess I need more time to review this feature.
mgrachev: > I added the field with a checker name to `Warning` (it is possible to determine the checker by the text of the message, but I don't like this approach).

I'd like to minimize the changes in this PR, so I suppose to transfer this item to another PR.
DDtKey: Thanks for the contribution, autofix are a really important part of development :rocket: 

I would like to consider this PR a little more when I will have a bit more free time.

It seems important to me to discuss the moment, with a fix using the `Warnings`.
The ability to categorize as “fixed” and “not fixed” really _looks great!_ :fire: 

But some questions worry me:
1) Will this API accurately make it easy to scale the application (add new fixes for checks)?
2) Is there enough information in the warnings to correct various types of warnings?

Perhaps the fixers should still be able to work **without** a preliminary check, what do you think?

The fact is that we are unlikely to be able to use the information, let's say about the sorting order, we’ll just have to fix the whole file. And roughly speaking the `Warning` information gives not so much in such cases. It also possibly affects performance, although not critically.

Maybe I'm completely wrong, but I want to raise this issue to look into the distance :thinking: 

@dotenv-linter/core
evgeniy-r: > I'd like to minimize the changes in this PR, so I suppose to transfer this item to another PR.

I'm agree, I will do it in a couple of days.
evgeniy-r: @DDtKey, thank you for feedback!

> Will this API accurately make it easy to scale the application (add new fixes for checks)?

Important question. Yes, I experimented a bit and I'm sure that it will be possible to implement all existing checks based on this approach.

> Is there enough information in the warnings to correct various types of warnings?

Sometimes (in several checks) we will work with the entire line entry collection and will use warnings only for indication (`Fixed`).
These checks (preliminarily): `DuplicatedKey`, `ExtraBlankLine`, `UnorderedKey`.

> Perhaps the fixers should still be able to work without a preliminary check, what do you think?

I’ll think it over, but it seems to me that in terms of performance, checking first and then fixing is not a problem. On the other hand, we clearly link checks and fixes now.

> The fact is that we are unlikely to be able to use the information, let's say about the sorting order, we’ll just have to fix the whole file. 

For some checks, this is true, for others - not.
DDtKey: I apologize, I deleted the question before I received the answer, because I answered myself to some extent :disappointed: 

I will return it to leave a story:
> Thanks for the contribution, autofix are a really important part of development :rocket: 
> 
> I would like to consider this PR a little more when I will have a bit more free time.
> 
> It seems important to me to discuss the moment, with a fix using the `Warnings`.
> The ability to categorize as “fixed” and “not fixed” really _looks great!_ :fire: 
> 
> But some questions worry me:
> 1) Will this API accurately make it easy to scale the application (add new fixes for checks)?
> 2) Is there enough information in the warnings to correct various types of warnings?
> 
> Perhaps the fixers should still be able to work without a preliminary check, what do you think?
> 
> The fact is that we are unlikely to be able to use the information, let's say about the sorting order, we’ll just have to fix the whole file. And roughly speaking the `Warning` information gives not so much in such cases. It also possibly affects performance, although not critically.
> 
> At the same time, it is absolutely correct to display warnings that we cannot correct or are ambiguous (for example: `IncorrectDelimiterChecker`).
> 
> Maybe I'm completely wrong, but I want to raise this issue to look into the distance :thinking: 


@evgeniy-r Thanks for the detailed answer, I agree that the approach justifies itself.
evgeniy-r: I have splitted this PR into two (https://github.com/dotenv-linter/dotenv-linter/pull/234).
I can't push the branch to this repo and rebase this PR to another branch, so there are 2 commits for now (one for each PR).
mgrachev: @evgeniy-r The PR https://github.com/dotenv-linter/dotenv-linter/pull/234 has been merged. Please, update this PR.
evgeniy-r: I have updated PR.
evgeniy-r: @mgrachev, @DDtKey thank you very much for your feedback.
I will make changes.
evgeniy-r: I changed the output format and there is no need in `Option<bool>` anymore (since the output format of each message no longer depends on the fixing option). I will simplify the code.
evgeniy-r: Also there is some problem with tests on Windows platform - I will look in more detail.
evgeniy-r: > 
> 
> Also there is some problem with tests on Windows platform - I will look in more detail.

I have fixed tests, everything is OK.
codecov-commenter: # [Codecov](https://codecov.io/gh/dotenv-linter/dotenv-linter/pull/228?src=pr&el=h1) Report
> Merging [#228](https://codecov.io/gh/dotenv-linter/dotenv-linter/pull/228?src=pr&el=desc) into [master](https://codecov.io/gh/dotenv-linter/dotenv-linter/commit/e947f60b1ae424c08f1e3c17a8ca1cc651bd29eb&el=desc) will **increase** coverage by `0.09%`.
> The diff coverage is `98.46%`.

[![Impacted file tree graph](https://codecov.io/gh/dotenv-linter/dotenv-linter/pull/228/graphs/tree.svg?width=650&height=150&src=pr&token=wQybePfHI1)](https://codecov.io/gh/dotenv-linter/dotenv-linter/pull/228?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #228      +/-   ##
==========================================
+ Coverage   97.35%   97.44%   +0.09%     
==========================================
  Files          16       18       +2     
  Lines        1510     1686     +176     
==========================================
+ Hits         1470     1643     +173     
- Misses         40       43       +3     
```


| [Impacted Files](https://codecov.io/gh/dotenv-linter/dotenv-linter/pull/228?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/common.rs](https://codecov.io/gh/dotenv-linter/dotenv-linter/pull/228/diff?src=pr&el=tree#diff-c3JjL2NvbW1vbi5ycw==) | `97.11% <75.00%> (-1.02%)` | :arrow_down: |
| [src/checks.rs](https://codecov.io/gh/dotenv-linter/dotenv-linter/pull/228/diff?src=pr&el=tree#diff-c3JjL2NoZWNrcy5ycw==) | `100.00% <100.00%> (ø)` | |
| [src/fixes.rs](https://codecov.io/gh/dotenv-linter/dotenv-linter/pull/228/diff?src=pr&el=tree#diff-c3JjL2ZpeGVzLnJz) | `100.00% <100.00%> (ø)` | |
| [src/fixes/lowercase\_key.rs](https://codecov.io/gh/dotenv-linter/dotenv-linter/pull/228/diff?src=pr&el=tree#diff-c3JjL2ZpeGVzL2xvd2VyY2FzZV9rZXkucnM=) | `100.00% <100.00%> (ø)` | |
| [src/fs\_utils.rs](https://codecov.io/gh/dotenv-linter/dotenv-linter/pull/228/diff?src=pr&el=tree#diff-c3JjL2ZzX3V0aWxzLnJz) | `100.00% <100.00%> (ø)` | |
| [src/lib.rs](https://codecov.io/gh/dotenv-linter/dotenv-linter/pull/228/diff?src=pr&el=tree#diff-c3JjL2xpYi5ycw==) | `94.91% <100.00%> (+0.27%)` | :arrow_up: |
| [src/main.rs](https://codecov.io/gh/dotenv-linter/dotenv-linter/pull/228/diff?src=pr&el=tree#diff-c3JjL21haW4ucnM=) | `90.24% <100.00%> (+9.29%)` | :arrow_up: |
| [src/checks/incorrect\_delimiter.rs](https://codecov.io/gh/dotenv-linter/dotenv-linter/pull/228/diff?src=pr&el=tree#diff-c3JjL2NoZWNrcy9pbmNvcnJlY3RfZGVsaW1pdGVyLnJz) | `97.02% <0.00%> (-0.03%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/dotenv-linter/dotenv-linter/pull/228?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/dotenv-linter/dotenv-linter/pull/228?src=pr&el=footer). Last update [e947f60...a6ba94f](https://codecov.io/gh/dotenv-linter/dotenv-linter/pull/228?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

evgeniy-r: I seem to have answered all the comments.
I can rebase on the master branch, if it is needed.
DDtKey: You did a great job! :+1:
Thank you!  🙏
This will be great ground for release v2.2.0, with the primary goal of implementing the remaining fixers 📄
evgeniy-r: Sometimes I see that tests on Windows failed:
https://github.com/dotenv-linter/dotenv-linter/pull/228/checks?check_run_id=862439701

(problem with deleting a temporary file)
evgeniy-r: @mgrachev, I have given different names to the temp files in `fs_utils` tests (but I'm not sure if this is causing the error).
mgrachev: @DDtKey Can you help @evgeniy-r with tests on Windows?
DDtKey: > @DDtKey Can you help @evgeniy-r with tests on Windows?

Yes, of course!

As soon as I am get free time, I will definitely check.

> @mgrachev, I have given different names to the temp files in `fs_utils` tests (but I'm not sure if this is causing the error).

I think this is due to the `temp_dir` method.  It works differently than `tempdir` and does not give the same lifetime guarantees.
evgeniy-r: https://doc.rust-lang.org/std/env/fn.temp_dir.html

**Windows**
Returns the value of, in order, the TMP, TEMP, USERPROFILE environment variable if any are set and not the empty string. Otherwise, `temp_dir` returns the path of the Windows directory.
DDtKey: > https://doc.rust-lang.org/std/env/fn.temp_dir.html
> 
> **Windows**
> Returns the value of, in order, the TMP, TEMP, USERPROFILE environment variable if any are set and not the empty string. Otherwise, `temp_dir` returns the path of the Windows directory.

Yes, but `File::create` also does not change the file name according to the specification.

Among the action logs, I noticed a `AccessDenied`.  Therefore, I just assumed.  Now, unfortunately, I can’t look at it from a PC, but I’ll definitely check later.
evgeniy-r: I was able to get PC )
Before renaming: 7 fails from 20 test runs.
After renaming: 0 fails from 20 test runs.
DDtKey: > I was able to get PC )
> Before renaming: 7 fails from 20 test runs.
> After renaming: 0 fails from 20 test runs.

Sorry, I mean, it's hard for me to comment, because I'm writing now from a smartphone 😔

This is interesting, because this has not happened with tests in the master branch so far, both on a personal laptop with Windows and in github action :thinking:
evgeniy-r: There is only one such test in the master branch.

You should look at this commit:
https://github.com/dotenv-linter/dotenv-linter/pull/228/commits/893d5308539999338e004ca44467e3c009afa600
DDtKey: > There is only one such test in the master branch.


I think this does not change much, but I know two working on a similar principle: [second example](https://github.com/dotenv-linter/dotenv-linter/blob/e947f60b1ae424c08f1e3c17a8ca1cc651bd29eb/src/common.rs#L187)


DDtKey: > I was able to get PC )
> Before renaming: 7 fails from 20 test runs.
> After renaming: 0 fails from 100+ test runs.

Perhaps the matter is that tests are performed in parallel?
One test create a file with a specific name, another, begins to read or delete it?  We create a file in a temporary folder, but the same (`temp_dir()`).
While `tmpdir` creates a unique temporary folder.
evgeniy-r: And there is no fails on Lunux.
Are you able to reproduce same results?

DDtKey: > And there is no fails on Lunux (maybe Windows just does not delete the file immediately).
> Are you able to reproduce same results?

I was able to reproduce this.
If the tests (with `temp_dir` + same file name) are in the same module, we easily come across this on `Windows`,
and I have it **reproduced on Linux including**
Moreover, this does not happen with the `cargo test - --test-threads 1` command, which confirms the above.

DDtKey: I think, we should use something like that:
```
        let file_name = String::from(".env");
        let tempdir = tempdir().expect("create tempdir");
        let path = tempdir.path().join(file_name);
``` 
This solves the problem of unique names and concurrency. 
How this works in integration tests.
evgeniy-r: > I was able to reproduce this.
> If the tests (with temp_dir + same file name) are in the same test module, we easily come across this on Windows,
> and I have it reproduced on Linux including
> Moreover, this does not happen with the cargo test - --test-threads 1 command, which confirms the above.

:+1: It is an excellent finding!
I agree that we should use randomized file names.
evgeniy-r: I added `tempdir` usage to tests. Also rebased to the fresh master.
evgeniy-r: > In that case we should print an error instead of panic. Maybe we should use the Result enum instead of Option

OK, I will take a look.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3ed6d28..58993d5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ # Changelog
 
 ## [Unreleased]
 ### 🚀 Added
+- Add autofix feature (for LowercaseKey check) [#228](https://github.com/dotenv-linter/dotenv-linter/pull/228) ([@evgeniy-r](https://github.com/evgeniy-r))
 - Add installation CI test for Windows (via `install.sh`) [#235](https://github.com/dotenv-linter/dotenv-linter/pull/235) ([@DDtKey](https://github.com/DDtKey))
 
 ### 🔧 Changed
diff --git a/README.md b/README.md
index e659087..39a2a9f 100644
--- a/README.md
+++ b/README.md
@@ -209,6 +209,18 @@ ## 🚀 Usage
 UnorderedKey
 ```
 
+`dotenv-linter` can also automatically fix warnings in the files. Currently only one kind of warnings is fixed
+(`LowercaseKey`). You should use the argument `--fix` (or its short version `-f`) for this (will be available in [v2.2.0](https://github.com/dotenv-linter/dotenv-linter/issues/238):
+
+```shell script
+$ dotenv-linter -f
+Fixed warnings:
+.env:3 LowercaseKey: The foo key should be in uppercase
+
+Unfixed warnings:
+.env:2 DuplicatedKey: The BAR key is duplicated
+```
+
 ## ✅ Checks
 
 ### Duplicated Key
diff --git a/src/checks.rs b/src/checks.rs
index be57f19..8a0b72c 100644
--- a/src/checks.rs
+++ b/src/checks.rs
@@ -45,13 +45,13 @@ pub fn available_check_names() -> Vec<String> {
         .collect()
 }
 
-pub fn run(lines: Vec<LineEntry>, skip_checks: &[&str]) -> Vec<Warning> {
+pub fn run(lines: &[LineEntry], skip_checks: &[&str]) -> Vec<Warning> {
     let mut checks = checklist();
     checks.retain(|c| !skip_checks.contains(&c.name()));
 
     let mut warnings: Vec<Warning> = Vec::new();
 
-    for line in &lines {
+    for line in lines {
         let is_comment = line.is_comment();
         for ch in &mut checks {
             if is_comment && ch.skip_comments() {
@@ -101,7 +101,7 @@ fn run_with_empty_vec_test() {
         let expected: Vec<Warning> = Vec::new();
         let skip_checks: Vec<&str> = Vec::new();
 
-        assert_eq!(expected, run(empty, &skip_checks));
+        assert_eq!(expected, run(&empty, &skip_checks));
     }
 
     #[test]
@@ -110,7 +110,7 @@ fn run_with_empty_line_test() {
         let expected: Vec<Warning> = Vec::new();
         let skip_checks: Vec<&str> = Vec::new();
 
-        assert_eq!(expected, run(lines, &skip_checks));
+        assert_eq!(expected, run(&lines, &skip_checks));
     }
 
     #[test]
@@ -122,7 +122,7 @@ fn run_with_comment_line_test() {
         let expected: Vec<Warning> = Vec::new();
         let skip_checks: Vec<&str> = Vec::new();
 
-        assert_eq!(expected, run(lines, &skip_checks));
+        assert_eq!(expected, run(&lines, &skip_checks));
     }
 
     #[test]
@@ -131,7 +131,7 @@ fn run_with_valid_line_test() {
         let expected: Vec<Warning> = Vec::new();
         let skip_checks: Vec<&str> = Vec::new();
 
-        assert_eq!(expected, run(lines, &skip_checks));
+        assert_eq!(expected, run(&lines, &skip_checks));
     }
 
     #[test]
@@ -146,7 +146,7 @@ fn run_with_invalid_line_test() {
         let expected: Vec<Warning> = vec![warning];
         let skip_checks: Vec<&str> = Vec::new();
 
-        assert_eq!(expected, run(lines, &skip_checks));
+        assert_eq!(expected, run(&lines, &skip_checks));
     }
 
     #[test]
@@ -161,7 +161,7 @@ fn run_without_blank_line_test() {
         let expected: Vec<Warning> = vec![warning];
         let skip_checks: Vec<&str> = Vec::new();
 
-        assert_eq!(expected, run(lines, &skip_checks));
+        assert_eq!(expected, run(&lines, &skip_checks));
     }
 
     #[test]
@@ -177,7 +177,7 @@ fn skip_one_check() {
         let expected: Vec<Warning> = vec![warning];
         let skip_checks: Vec<&str> = vec!["KeyWithoutValue"];
 
-        assert_eq!(expected, run(lines, &skip_checks));
+        assert_eq!(expected, run(&lines, &skip_checks));
     }
 
     #[test]
@@ -187,7 +187,7 @@ fn skip_all_checks() {
         let expected: Vec<Warning> = Vec::new();
         let skip_checks: Vec<&str> = vec!["KeyWithoutValue", "EndingBlankLine"];
 
-        assert_eq!(expected, run(lines, &skip_checks));
+        assert_eq!(expected, run(&lines, &skip_checks));
     }
 
     #[test]
diff --git a/src/common.rs b/src/common.rs
index f90be5d..56cea2b 100644
--- a/src/common.rs
+++ b/src/common.rs
@@ -4,9 +4,10 @@
 
 #[derive(Clone, Debug, PartialEq)]
 pub struct Warning {
-    check_name: String,
+    pub check_name: String,
     line: LineEntry,
     message: String,
+    pub is_fixed: bool,
 }
 
 impl Warning {
@@ -16,8 +17,21 @@ pub fn new(line: LineEntry, check_name: &str, message: String) -> Self {
             line,
             check_name,
             message,
+            is_fixed: false,
         }
     }
+
+    pub fn line_number(&self) -> usize {
+        self.line.number
+    }
+
+    pub fn mark_as_fixed(&mut self) {
+        self.is_fixed = true;
+    }
+
+    pub fn mark_as_unfixed(&mut self) {
+        self.is_fixed = false;
+    }
 }
 
 impl fmt::Display for Warning {
@@ -174,8 +188,6 @@ mod file_entry {
 
         mod from {
             use super::*;
-            use std::env::temp_dir;
-            use std::fs::remove_file;
 
             #[test]
             fn path_without_file_test() {
@@ -186,14 +198,15 @@ fn path_without_file_test() {
             #[test]
             fn path_with_file_test() {
                 let file_name = String::from(".env");
-                let path = temp_dir().join(&file_name);
+                let dir = tempfile::tempdir().expect("create temp dir");
+                let path = dir.path().join(&file_name);
                 fs::File::create(&path).expect("create testfile");
 
                 let f = FileEntry::from(path.clone());
                 assert_eq!(
                     Some((
                         FileEntry {
-                            path: path.clone(),
+                            path,
                             file_name,
                             total_lines: 0
                         },
@@ -201,7 +214,7 @@ fn path_with_file_test() {
                     )),
                     f
                 );
-                remove_file(path).expect("temp file deleted");
+                dir.close().expect("temp dir deleted");
             }
         }
 
diff --git a/src/fixes.rs b/src/fixes.rs
new file mode 100644
index 0000000..9b0d670
--- /dev/null
+++ b/src/fixes.rs
@@ -0,0 +1,167 @@
+use crate::common::*;
+
+mod lowercase_key;
+
+trait Fix {
+    fn name(&self) -> &str;
+
+    fn fix_warnings(
+        &self,
+        warnings: Vec<&mut Warning>,
+        lines: &mut Vec<LineEntry>,
+    ) -> Option<usize> {
+        let mut count: usize = 0;
+        for warning in warnings {
+            let line = lines.get_mut(warning.line_number() - 1)?;
+            if self.fix_line(line).is_some() {
+                warning.mark_as_fixed();
+                count += 1;
+            }
+        }
+
+        Some(count)
+    }
+
+    fn fix_line(&self, _line: &mut LineEntry) -> Option<()> {
+        None
+    }
+}
+
+// TODO: skip fixes (like checks)
+// The fix order is matter
+fn fixlist() -> Vec<Box<dyn Fix>> {
+    vec![
+        // At first we run the fixers that handle a single line entry (they use default
+        // implementation of the fix_warnings() function)
+        Box::new(lowercase_key::LowercaseKeyFixer::default()),
+        // Then we should run the fixers that handle the line entry collection at whole.
+        // And at the end we should run the fixer for ExtraBlankLine check (because the previous
+        // fixers can create additional extra blank lines).
+    ]
+}
+
+pub fn run(warnings: &mut [Warning], lines: &mut Vec<LineEntry>) -> usize {
+    if warnings.is_empty() {
+        return 0;
+    }
+
+    let mut count = 0;
+    for fixer in fixlist() {
+        // We can optimize it: create check_name:warnings map in advance
+        let fixer_warnings: Vec<&mut Warning> = warnings
+            .iter_mut()
+            .filter(|w| w.check_name == fixer.name())
+            .collect();
+
+        if !fixer_warnings.is_empty() {
+            match fixer.fix_warnings(fixer_warnings, lines) {
+                Some(fixer_count) => count += fixer_count,
+                None => {
+                    for warning in warnings {
+                        warning.mark_as_unfixed();
+                    }
+                    return 0;
+                }
+            }
+        }
+    }
+
+    count
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::path::PathBuf;
+
+    fn line_entry(number: usize, total_lines: usize, str: &str) -> LineEntry {
+        LineEntry {
+            number,
+            file: FileEntry {
+                path: PathBuf::from(".env"),
+                file_name: ".env".to_string(),
+                total_lines,
+            },
+            raw_string: String::from(str),
+        }
+    }
+
+    fn blank_line_entry(number: usize, total_lines: usize) -> LineEntry {
+        LineEntry {
+            number,
+            file: FileEntry {
+                path: PathBuf::from(".env"),
+                file_name: ".env".to_string(),
+                total_lines,
+            },
+            raw_string: String::from("\n"),
+        }
+    }
+
+    #[test]
+    fn run_with_empty_warnings_test() {
+        let mut lines = vec![line_entry(1, 2, "A=B"), blank_line_entry(2, 2)];
+        let mut warnings: Vec<Warning> = Vec::new();
+
+        assert_eq!(0, run(&mut warnings, &mut lines));
+    }
+
+    #[test]
+    fn run_with_fixable_warning_test() {
+        let mut lines = vec![
+            line_entry(1, 3, "A=B"),
+            line_entry(2, 3, "c=d"),
+            blank_line_entry(3, 3),
+        ];
+        let mut warnings = vec![Warning::new(
+            lines[1].clone(),
+            "LowercaseKey",
+            String::from("The c key should be in uppercase"),
+        )];
+
+        assert_eq!(1, run(&mut warnings, &mut lines));
+        assert_eq!("C=d", lines[1].raw_string);
+        assert!(warnings[0].is_fixed);
+    }
+
+    #[test]
+    fn run_with_unfixable_warning_test() {
+        let mut lines = vec![
+            line_entry(1, 3, "A=B"),
+            line_entry(2, 3, "C"),
+            blank_line_entry(3, 3),
+        ];
+        let mut warnings = vec![Warning::new(
+            lines[1].clone(),
+            "KeyWithoutValue",
+            String::from("The C key should be with a value or have an equal sign"),
+        )];
+
+        assert_eq!(0, run(&mut warnings, &mut lines));
+        assert!(!warnings[0].is_fixed);
+    }
+
+    #[test]
+    fn run_when_lines_do_not_fit_numbers_test() {
+        let mut lines = vec![
+            line_entry(1, 3, "a=B"),
+            line_entry(4, 3, "c=D"),
+            blank_line_entry(3, 3),
+        ];
+        let mut warnings = vec![
+            Warning::new(
+                lines[0].clone(),
+                "LowercaseKey",
+                String::from("The a key should be in uppercase"),
+            ),
+            Warning::new(
+                lines[1].clone(),
+                "LowercaseKey",
+                String::from("The c key should be in uppercase"),
+            ),
+        ];
+
+        assert_eq!(0, run(&mut warnings, &mut lines));
+        assert!(!warnings[0].is_fixed);
+    }
+}
diff --git a/src/fixes/lowercase_key.rs b/src/fixes/lowercase_key.rs
new file mode 100644
index 0000000..8f3ec74
--- /dev/null
+++ b/src/fixes/lowercase_key.rs
@@ -0,0 +1,93 @@
+use super::Fix;
+use crate::common::*;
+
+pub(crate) struct LowercaseKeyFixer<'a> {
+    name: &'a str,
+}
+
+impl Default for LowercaseKeyFixer<'_> {
+    fn default() -> Self {
+        Self {
+            name: "LowercaseKey",
+        }
+    }
+}
+
+impl Fix for LowercaseKeyFixer<'_> {
+    fn name(&self) -> &str {
+        self.name
+    }
+
+    fn fix_line(&self, line: &mut LineEntry) -> Option<()> {
+        let key = line.get_key()?;
+        let key = key.to_uppercase();
+        line.raw_string = format!("{}={}", key, line.get_value()?);
+
+        Some(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::path::PathBuf;
+
+    #[test]
+    fn fix_line_test() {
+        let fixer = LowercaseKeyFixer::default();
+        let mut line = LineEntry {
+            number: 1,
+            file: FileEntry {
+                path: PathBuf::from(".env"),
+                file_name: ".env".to_string(),
+                total_lines: 1,
+            },
+            raw_string: String::from("foO=BAR"),
+        };
+        assert_eq!(Some(()), fixer.fix_line(&mut line));
+        assert_eq!("FOO=BAR", line.raw_string);
+    }
+
+    #[test]
+    fn fix_warnings_test() {
+        let fixer = LowercaseKeyFixer::default();
+        let mut lines = vec![
+            LineEntry {
+                number: 1,
+                file: FileEntry {
+                    path: PathBuf::from(".env"),
+                    file_name: ".env".to_string(),
+                    total_lines: 3,
+                },
+                raw_string: String::from("foO=BAR"),
+            },
+            LineEntry {
+                number: 2,
+                file: FileEntry {
+                    path: PathBuf::from(".env"),
+                    file_name: ".env".to_string(),
+                    total_lines: 3,
+                },
+                raw_string: String::from("Z=Y"),
+            },
+            LineEntry {
+                number: 3,
+                file: FileEntry {
+                    path: PathBuf::from(".env"),
+                    file_name: ".env".to_string(),
+                    total_lines: 3,
+                },
+                raw_string: String::from("\n"),
+            },
+        ];
+        let mut warning = Warning::new(
+            lines[0].clone(),
+            "LowercaseKey",
+            String::from("The FOO key should be in uppercase"),
+        );
+
+        assert_eq!(Some(1), fixer.fix_warnings(vec![&mut warning], &mut lines));
+        assert_eq!("FOO=BAR", lines[0].raw_string);
+        assert!(warning.is_fixed);
+    }
+}
diff --git a/src/fs_utils.rs b/src/fs_utils.rs
index ca6ca2f..38d61ae 100644
--- a/src/fs_utils.rs
+++ b/src/fs_utils.rs
@@ -1,3 +1,6 @@
+use crate::common::LineEntry;
+use std::fs::File;
+use std::io::{self, Write};
 use std::path::PathBuf;
 
 /// For the Windows platform, we need to remove the UNC prefix.
@@ -31,11 +34,24 @@ pub fn get_relative_path(target_path: &PathBuf, base_path: &PathBuf) -> Option<P
     Some(relative_path)
 }
 
+/// In the future versions we should create a backup copy, or at least notify the user about it
+pub fn write_file(path: &PathBuf, lines: Vec<LineEntry>) -> io::Result<()> {
+    let mut file = File::create(path)?;
+
+    // We don't write the last line, because it contains only LF (common::FileEntry::from)
+    // and writeln! already adds LF.
+    for line in lines[..lines.len() - 1].iter() {
+        writeln!(file, "{}", line.raw_string)?;
+    }
+
+    Ok(())
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
-    use std::env::temp_dir;
-    use std::fs::{remove_file, File};
+    use crate::common::FileEntry;
+    use std::fs::{self, File};
 
     fn run_relative_path_asserts(assertions: Vec<(&str, &str, &str)>) {
         for (target, base, relative) in assertions {
@@ -76,7 +92,8 @@ fn test_relative_path() {
     #[test]
     #[cfg(not(windows))]
     fn test_canonicalize() {
-        let path = temp_dir().join(".env");
+        let dir = tempfile::tempdir().expect("create temp dir");
+        let path = dir.path().join(".env");
         File::create(&path).expect("file created");
 
         let fs_canonical_path = std::fs::canonicalize(&path).expect("canonical path by std::fs");
@@ -85,7 +102,7 @@ fn test_canonicalize() {
         // The result of `fs_utils` must match `std::fs` for non-Windows platform
         assert_eq!(canonical_path, fs_canonical_path);
 
-        remove_file(path).expect("temp file deleted");
+        dir.close().expect("temp dir deleted");
     }
 
     #[test]
@@ -94,7 +111,8 @@ fn test_canonicalize() {
         const UNC_PREFIX: &str = "\\\\?\\";
 
         let file_name = String::from(".env");
-        let path = temp_dir().join(&file_name);
+        let dir = tempfile::tempdir().expect("create temp dir");
+        let path = dir.path().join(&file_name);
         File::create(&path).expect("create testfile");
 
         let dunce_canonical_path = dunce::canonicalize(&path).expect("canonical path by `dunce`");
@@ -109,6 +127,45 @@ fn test_canonicalize() {
         assert_eq!(canonical_path, dunce_canonical_path);
         assert!(!contains_unc);
 
-        remove_file(path).expect("temp file deleted");
+        dir.close().expect("temp dir deleted");
+    }
+
+    #[test]
+    fn write_file_test() {
+        let file_name = String::from(".env");
+        let dir = tempfile::tempdir().expect("create temp dir");
+        let path = dir.path().join(&file_name);
+
+        let fe = FileEntry {
+            path: path.clone(),
+            file_name,
+            total_lines: 3,
+        };
+
+        let lines = vec![
+            LineEntry {
+                number: 1,
+                file: fe.clone(),
+                raw_string: String::from("A=B"),
+            },
+            LineEntry {
+                number: 2,
+                file: fe.clone(),
+                raw_string: String::from("Z=Y"),
+            },
+            LineEntry {
+                number: 3,
+                file: fe,
+                raw_string: String::from("\n"),
+            },
+        ];
+
+        assert!(write_file(&path, lines).is_ok());
+        assert_eq!(
+            b"A=B\nZ=Y\n",
+            fs::read(path.as_path()).expect("file read").as_slice()
+        );
+
+        dir.close().expect("temp dir deleted");
     }
 }
diff --git a/src/lib.rs b/src/lib.rs
index 4ba3b54..a252002 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -5,6 +5,7 @@
 
 mod checks;
 mod common;
+mod fixes;
 mod fs_utils;
 
 pub use checks::available_check_names;
@@ -35,6 +36,7 @@ pub fn run(args: clap::ArgMatches, current_dir: &PathBuf) -> Result<Vec<Warning>
         file_paths.extend(get_file_paths(input_paths, &excluded_paths, is_recursive));
     }
 
+    let is_fix = args.is_present("fix");
     let mut warnings: Vec<Warning> = Vec::new();
 
     for path in file_paths {
@@ -43,14 +45,18 @@ pub fn run(args: clap::ArgMatches, current_dir: &PathBuf) -> Result<Vec<Warning>
             None => continue,
         };
 
-        let file_with_lines = match FileEntry::from(relative_path) {
+        let (fe, strs) = match FileEntry::from(relative_path) {
             Some(f) => f,
             None => continue,
         };
 
-        let lines = get_line_entries(file_with_lines.0, file_with_lines.1);
+        let mut lines = get_line_entries(&fe, strs);
+
+        let mut result = checks::run(&lines, &skip_checks);
+        if is_fix && fixes::run(&mut result, &mut lines) > 0 {
+            fs_utils::write_file(&fe.path, lines)?;
+        }
 
-        let result = checks::run(lines, &skip_checks);
         warnings.extend(result);
     }
 
@@ -89,7 +95,7 @@ fn get_file_paths(
     file_paths
 }
 
-fn get_line_entries(fe: FileEntry, lines: Vec<String>) -> Vec<LineEntry> {
+fn get_line_entries(fe: &FileEntry, lines: Vec<String>) -> Vec<LineEntry> {
     let mut entries: Vec<LineEntry> = Vec::with_capacity(fe.total_lines);
 
     for (index, line) in lines.into_iter().enumerate() {
diff --git a/src/main.rs b/src/main.rs
index 1ada611..e218698 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -14,12 +14,36 @@ fn main() -> Result<(), Box<dyn Error>> {
         process::exit(0);
     }
 
+    let is_fix = args.is_present("fix");
+
     let warnings = dotenv_linter::run(args, &current_dir)?;
+
     if warnings.is_empty() {
         process::exit(0);
     }
 
-    warnings.iter().for_each(|w| println!("{}", w));
+    if is_fix {
+        if warnings.iter().any(|w| w.is_fixed) {
+            println!("Fixed warnings:");
+            warnings
+                .iter()
+                .filter(|w| w.is_fixed)
+                .for_each(|w| println!("{}", w));
+        }
+
+        if warnings.iter().any(|w| !w.is_fixed) {
+            println!("\nUnfixed warnings:");
+            warnings
+                .iter()
+                .filter(|w| !w.is_fixed)
+                .for_each(|w| println!("{}", w));
+        } else {
+            process::exit(0);
+        }
+    } else {
+        warnings.iter().for_each(|w| println!("{}", w));
+    }
+
     process::exit(1);
 }
 
@@ -66,5 +90,11 @@ fn get_args(current_dir: &OsStr) -> clap::ArgMatches {
                 .long("recursive")
                 .help("Recursively search and check .env files"),
         )
+        .arg(
+            Arg::with_name("fix")
+                .short("f")
+                .long("fix")
+                .help("Automatically fixes warnings if possible"),
+        )
         .get_matches()
 }


Test patch:
diff --git a/tests/cli.rs b/tests/cli.rs
index d42e2cc..820181c 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -1,4 +1,5 @@
 mod args;
 mod checks;
 mod common;
+mod fixes;
 mod options;
diff --git a/tests/common/mod.rs b/tests/common/mod.rs
index 80928d9..39dac79 100644
--- a/tests/common/mod.rs
+++ b/tests/common/mod.rs
@@ -1,6 +1,6 @@
 use assert_cmd::Command;
 use std::ffi::OsStr;
-use std::fs::File;
+use std::fs::{self, File};
 use std::io::Write;
 use std::path::PathBuf;
 use tempfile::{tempdir, tempdir_in, TempDir};
@@ -116,6 +116,29 @@ pub fn test_command_fail_with_args<I, S>(self, args: I, expected_output: String)
         self.close();
     }
 
+    /// Run the default CLI binary, with "-f", in this TestDir and check it succeeds.
+    pub fn test_command_fix_success(&self, expected_output: String) {
+        let mut cmd = Self::init_cmd();
+        let canonical_current_dir = canonicalize(&self.current_dir).expect("canonical current dir");
+        cmd.current_dir(&canonical_current_dir)
+            .args(&["-f"])
+            .assert()
+            .success()
+            .stdout(expected_output);
+    }
+
+    /// Run the default CLI binary, with "-f", in this TestDir and check it fails.
+    pub fn test_command_fix_fail(&self, expected_output: String) {
+        let mut cmd = Self::init_cmd();
+        let canonical_current_dir = canonicalize(&self.current_dir).expect("canonical current dir");
+        cmd.current_dir(&canonical_current_dir)
+            .args(&["-f"])
+            .assert()
+            .failure()
+            .code(1)
+            .stdout(expected_output);
+    }
+
     fn init_cmd() -> Command {
         Command::cargo_bin(env!("CARGO_PKG_NAME")).expect("command from binary name")
     }
@@ -149,4 +172,9 @@ pub fn shortname_as_str(&self) -> &str {
             .to_str()
             .expect("convert shortname to &str")
     }
+
+    /// Get file contents
+    pub fn contents(&self) -> String {
+        String::from_utf8_lossy(&fs::read(self.as_str()).expect("read file")).into_owned()
+    }
 }
diff --git a/tests/fixes/mod.rs b/tests/fixes/mod.rs
new file mode 100644
index 0000000..880965f
--- /dev/null
+++ b/tests/fixes/mod.rs
@@ -0,0 +1,73 @@
+use crate::common::TestDir;
+
+#[test]
+fn correct_file() {
+    let testdir = TestDir::new();
+    let testfile = testdir.create_testfile(".env", "ABC=DEF\nD=BAR\n\nFOO=BAR\n");
+
+    testdir.test_command_fix_success(String::new());
+
+    assert_eq!(testfile.contents().as_str(), "ABC=DEF\nD=BAR\n\nFOO=BAR\n");
+
+    testdir.close();
+}
+
+#[test]
+fn lowercase_key() {
+    let testdir = TestDir::new();
+    let testfile = testdir.create_testfile(".env", "abc=DEF\n\nfOO=BAR\n");
+    let expected_output = String::from(
+        "Fixed warnings:\n\
+        .env:1 LowercaseKey: The abc key should be in uppercase\n\
+        .env:3 LowercaseKey: The fOO key should be in uppercase\n",
+    );
+    testdir.test_command_fix_success(expected_output);
+
+    assert_eq!(testfile.contents().as_str(), "ABC=DEF\n\nFOO=BAR\n");
+
+    testdir.close();
+}
+
+#[test]
+fn unfixed_warnings() {
+    let testdir = TestDir::new();
+    let testfile = testdir.create_testfile(".env", "A=DEF\nB=BAR \nf=BAR\n");
+
+    let expected_output = String::from(
+        "Fixed warnings:\n\
+        .env:3 LowercaseKey: The f key should be in uppercase\n\
+        \n\
+        Unfixed warnings:\n\
+        .env:2 TrailingWhitespace: Trailing whitespace detected\n",
+    );
+    testdir.test_command_fix_fail(expected_output);
+
+    assert_eq!(testfile.contents().as_str(), "A=DEF\nB=BAR \nF=BAR\n");
+
+    testdir.close();
+}
+
+#[test]
+fn multiple_files() {
+    let testdir = TestDir::new();
+
+    let testfile1 = testdir.create_testfile("1.env", "AB=DEF\nD=BAR\n\nF=BAR\n");
+    let testfile2 = testdir.create_testfile("2.env", "abc=DEF\n\nF=BAR\n");
+    let testfile3 = testdir.create_testfile("3.env", "A=b \nab=DEF\n");
+
+    let expected_output = String::from(
+        "Fixed warnings:\n\
+        2.env:1 LowercaseKey: The abc key should be in uppercase\n\
+        3.env:2 LowercaseKey: The ab key should be in uppercase\n\
+        \n\
+        Unfixed warnings:\n\
+        3.env:1 TrailingWhitespace: Trailing whitespace detected\n",
+    );
+    testdir.test_command_fix_fail(expected_output);
+
+    assert_eq!(testfile1.contents().as_str(), "AB=DEF\nD=BAR\n\nF=BAR\n");
+    assert_eq!(testfile2.contents().as_str(), "ABC=DEF\n\nF=BAR\n");
+    assert_eq!(testfile3.contents().as_str(), "A=b \nAB=DEF\n");
+
+    testdir.close();
+}